Skip to main content

dag_ml_core/
training.rs

1//! Frozen W1 training and portable-predictor contracts.
2//!
3//! This module is intentionally contract-only. It validates and projects the
4//! information needed by the future native training operation without running
5//! controllers or duplicating scheduler logic. Historical graph, campaign,
6//! controller and plan fingerprints keep their existing serde-JSON profile;
7//! only the new contracts in this module use TCV1.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12
13use crate::bundle::{bundle_prediction_requirement_key, ExecutionBundle, RefitArtifactRecord};
14use crate::canonical::parse_typed_json;
15use crate::conformal_runtime::ConformalCalibration;
16use crate::controller::{
17    ArtifactPolicy, ControllerCapability, ControllerFitScope, ControllerManifest,
18    ControllerRegistry,
19};
20use crate::criteria::TrainingLossRoleReference;
21use crate::data::{data_binding_requirement_key, DataBinding, ExternalDataPlanEnvelope};
22use crate::error::{DagMlError, Result};
23use crate::fold::fold_set_fingerprint;
24use crate::graph::{GraphSpec, NodeKind, PortKind};
25use crate::ids::{ArtifactId, BundleId, FoldId, GroupId, NodeId, SampleId};
26use crate::phase::Phase;
27use crate::plan::{build_execution_plan, CampaignSpec, ExecutionPlan};
28use crate::policy::PredictionLevel;
29use crate::relation::{EntityUnitLevel, SampleRelationSet};
30use crate::replay::{replay_request_from_outcome, TrainingReplayOutcome};
31use crate::selection::{RefitStrategy, SelectionPolicy};
32
33pub const TRAINING_REQUEST_SCHEMA_VERSION: u32 = 1;
34pub const TRAINING_REQUEST_SCHEMA_ID: &str =
35    "https://github.com/GBeurier/dag-ml/schemas/training_request.v1.schema.json";
36pub const CACHE_NAMESPACE_SCHEMA_VERSION: u32 = 1;
37pub const CACHE_NAMESPACE_SCHEMA_ID: &str =
38    "https://github.com/GBeurier/dag-ml/schemas/cache_namespace.v1.schema.json";
39/// V2 is the first package family permitted to carry closed conformal state.
40pub const PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION: u32 = 2;
41pub const LEGACY_PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION: u32 = 1;
42pub const MIN_READABLE_PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION: u32 = 1;
43pub const PORTABLE_PREDICTOR_PACKAGE_SCHEMA_ID: &str =
44    "https://github.com/GBeurier/dag-ml/schemas/portable_predictor_package.v1.schema.json";
45pub const OUTPUT_BINDING_SCHEMA_VERSION: u32 = 1;
46pub const TRAINING_INFLUENCE_MANIFEST_SCHEMA_VERSION: u32 = 1;
47pub const PARAMETER_PATCH_SCHEMA_VERSION: u32 = 1;
48pub const PARAMETER_PROJECTION_SCHEMA_VERSION: u32 = 1;
49
50type InfluenceCoordinate = (TrainingInfluenceKind, String, Option<NodeId>);
51type ExpectedInfluenceCoordinates = BTreeMap<InfluenceCoordinate, BTreeSet<SampleId>>;
52type InfluenceCapabilitySlot = (NodeId, TrainingInfluenceKind, Phase, Option<FoldId>);
53
54/// Deserialize a **required but nullable** field.
55///
56/// Wire semantics: the key MUST be present, yet its value may be an explicit
57/// JSON `null`. Paired with `#[serde(deserialize_with = ...)]` and **no**
58/// `#[serde(default)]`, this keeps "absent" and "present-and-null" distinct on
59/// the wire: an omitted key is a hard `missing field` error, while an explicit
60/// `null` maps to `None`. This differs from serde's default treatment of an
61/// `Option<T>` field, where omission silently becomes `None`. It matches the
62/// W1 JSON schemas that list these fields as `required` with an
63/// `anyOf [ T, null ]` value.
64fn deserialize_required_nullable<'de, D, T>(
65    deserializer: D,
66) -> std::result::Result<Option<T>, D::Error>
67where
68    D: serde::Deserializer<'de>,
69    T: Deserialize<'de>,
70{
71    Option::<T>::deserialize(deserializer)
72}
73
74#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum PredictionKind {
77    RegressionPoint,
78    ClassLabel,
79    ClassProbability,
80    DecisionScore,
81}
82
83#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum PredictionSource {
86    FinalRefit,
87    CvEnsemble,
88    FoldMember,
89}
90
91#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum OutputOrder {
94    TargetOrder,
95    TargetMajorClassMinor,
96}
97
98/// Requested output metadata before the producing port has been resolved.
99#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct TrainingOutputRequest {
102    pub output_id: String,
103    pub node_id: NodeId,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub port_name: Option<String>,
106    pub prediction_level: PredictionLevel,
107    #[serde(deserialize_with = "deserialize_required_nullable")]
108    pub unit_level: Option<EntityUnitLevel>,
109    pub prediction_kind: PredictionKind,
110    pub target_names: Vec<String>,
111    pub target_units: Vec<Option<String>>,
112    pub class_labels: Vec<Vec<String>>,
113    pub output_order: OutputOrder,
114    pub target_space: String,
115}
116
117#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct ResolvedTrainingOutput {
120    pub output_id: String,
121    pub node_id: NodeId,
122    pub port_name: String,
123    pub prediction_level: PredictionLevel,
124    #[serde(default)]
125    pub unit_level: Option<EntityUnitLevel>,
126    pub prediction_kind: PredictionKind,
127    pub target_names: Vec<String>,
128    pub target_units: Vec<Option<String>>,
129    pub class_labels: Vec<Vec<String>>,
130    pub output_order: OutputOrder,
131    pub target_space: String,
132}
133
134impl TrainingOutputRequest {
135    pub fn validate(&self) -> Result<()> {
136        validate_identifier_text("training output_id", &self.output_id)?;
137        validate_output_unit_level(self.prediction_level, self.unit_level)?;
138        validate_output_shape(
139            self.prediction_kind,
140            self.output_order,
141            &self.target_names,
142            &self.target_units,
143            &self.class_labels,
144            &self.target_space,
145        )?;
146        if self
147            .port_name
148            .as_ref()
149            .is_some_and(|name| name.trim().is_empty())
150        {
151            return contract_error(format!(
152                "training output `{}` has an empty port_name",
153                self.output_id
154            ));
155        }
156        Ok(())
157    }
158
159    /// Resolve the only prediction port when omitted, or validate an explicit
160    /// port. A producer with zero or multiple prediction ports is never guessed.
161    pub fn resolve(&self, graph: &GraphSpec) -> Result<ResolvedTrainingOutput> {
162        self.validate()?;
163        let node = graph
164            .nodes
165            .iter()
166            .find(|node| node.id == self.node_id)
167            .ok_or_else(|| {
168                DagMlError::CampaignValidation(format!(
169                    "training output `{}` references unknown node `{}`",
170                    self.output_id, self.node_id
171                ))
172            })?;
173        let prediction_ports = node
174            .ports
175            .outputs
176            .iter()
177            .filter(|port| port.kind == PortKind::Prediction)
178            .collect::<Vec<_>>();
179        let port_name = match self.port_name.as_deref() {
180            Some(requested) => {
181                let port = node
182                    .ports
183                    .outputs
184                    .iter()
185                    .find(|port| port.name == requested)
186                    .ok_or_else(|| {
187                        DagMlError::CampaignValidation(format!(
188                            "training output `{}` references absent port `{}.{requested}`",
189                            self.output_id, self.node_id
190                        ))
191                    })?;
192                if port.kind != PortKind::Prediction {
193                    return contract_error(format!(
194                        "training output `{}` port `{}.{requested}` is not a prediction port",
195                        self.output_id, self.node_id
196                    ));
197                }
198                requested.to_string()
199            }
200            None => match prediction_ports.as_slice() {
201                [] => {
202                    return contract_error(format!(
203                        "training output `{}` node `{}` exposes no prediction output",
204                        self.output_id, self.node_id
205                    ));
206                }
207                [only] => only.name.clone(),
208                _ => {
209                    return contract_error(format!(
210                        "training output `{}` node `{}` exposes multiple prediction outputs; port_name is required",
211                        self.output_id, self.node_id
212                    ));
213                }
214            },
215        };
216        Ok(ResolvedTrainingOutput {
217            output_id: self.output_id.clone(),
218            node_id: self.node_id.clone(),
219            port_name,
220            prediction_level: self.prediction_level,
221            unit_level: self.unit_level,
222            prediction_kind: self.prediction_kind,
223            target_names: self.target_names.clone(),
224            target_units: self.target_units.clone(),
225            class_labels: self.class_labels.clone(),
226            output_order: self.output_order,
227            target_space: self.target_space.clone(),
228        })
229    }
230}
231
232#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
233#[serde(rename_all = "snake_case")]
234pub enum TrainingSchedulerKind {
235    Sequential,
236    Parallel,
237}
238
239#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum TrainingSchedulerBackend {
242    Threads,
243    Processes,
244}
245
246#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct TrainingSchedulerOptions {
249    pub kind: TrainingSchedulerKind,
250    #[serde(default)]
251    pub backend: Option<TrainingSchedulerBackend>,
252    pub workers: u32,
253}
254
255impl TrainingSchedulerOptions {
256    fn validate(&self) -> Result<()> {
257        match (self.kind, self.backend, self.workers) {
258            (TrainingSchedulerKind::Sequential, None, 1) => Ok(()),
259            (TrainingSchedulerKind::Sequential, Some(_), _) => contract_error(
260                "sequential training scheduler forbids a parallel backend".to_string(),
261            ),
262            (TrainingSchedulerKind::Sequential, None, _) => {
263                contract_error("sequential training scheduler requires workers=1".to_string())
264            }
265            (TrainingSchedulerKind::Parallel, None, _) => contract_error(
266                "parallel training scheduler requires an explicit backend".to_string(),
267            ),
268            (TrainingSchedulerKind::Parallel, Some(_), 0 | 1) => {
269                contract_error("parallel training scheduler requires workers>=2".to_string())
270            }
271            (TrainingSchedulerKind::Parallel, Some(_), _) => Ok(()),
272        }
273    }
274}
275
276#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
277#[serde(deny_unknown_fields)]
278pub struct TrainingResourceLimits {
279    pub cpu_threads: u32,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub memory_bytes: Option<u64>,
282    // Required by schema: an empty list is valid, but omitting the key is not,
283    // so this field intentionally carries no `#[serde(default)]`.
284    pub gpu_devices: Vec<String>,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub wall_time_ms: Option<u64>,
287}
288
289impl TrainingResourceLimits {
290    fn validate(&self, scheduler: &TrainingSchedulerOptions) -> Result<()> {
291        if self.cpu_threads == 0 {
292            return contract_error("training resources require cpu_threads>=1".to_string());
293        }
294        if scheduler.workers > self.cpu_threads {
295            return contract_error(format!(
296                "training scheduler workers={} exceeds cpu_threads={}",
297                scheduler.workers, self.cpu_threads
298            ));
299        }
300        if self.memory_bytes == Some(0) {
301            return contract_error("training memory_bytes must be positive".to_string());
302        }
303        if self.wall_time_ms == Some(0) {
304            return contract_error("training wall_time_ms must be positive".to_string());
305        }
306        validate_sorted_unique_text("training gpu_devices", &self.gpu_devices, false)
307    }
308}
309
310#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case")]
312pub enum CvArtifactRetention {
313    Discard,
314    MetadataOnly,
315    Retain,
316}
317
318#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum PredictionCacheRetention {
321    Discard,
322    Retain,
323}
324
325#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
326#[serde(rename_all = "snake_case")]
327pub enum FittedArtifactMode {
328    PortableRequired,
329    AllowHostSidecar,
330}
331
332#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
333#[serde(deny_unknown_fields)]
334pub struct TrainingArtifactOptions {
335    pub cv_artifacts: CvArtifactRetention,
336    pub prediction_caches: PredictionCacheRetention,
337    pub fitted_artifacts: FittedArtifactMode,
338}
339
340#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
341#[serde(deny_unknown_fields)]
342pub struct TrainingOptions {
343    pub refit: bool,
344    #[serde(deserialize_with = "deserialize_required_nullable")]
345    pub refit_strategy: Option<RefitStrategy>,
346    pub seed: u64,
347    pub selection: SelectionPolicy,
348    pub selection_output_id: String,
349    pub outputs: Vec<TrainingOutputRequest>,
350    pub scheduler: TrainingSchedulerOptions,
351    pub resources: TrainingResourceLimits,
352    pub artifacts: TrainingArtifactOptions,
353}
354
355impl TrainingOptions {
356    fn validate(&self, graph: &GraphSpec) -> Result<Vec<ResolvedTrainingOutput>> {
357        match (self.refit, self.refit_strategy) {
358            (true, None) => {
359                return contract_error(
360                    "training refit=true requires an explicit refit_strategy".to_string(),
361                );
362            }
363            (false, Some(_)) => {
364                return contract_error("training refit=false forbids refit_strategy".to_string());
365            }
366            _ => {}
367        }
368        self.selection.validate()?;
369        validate_identifier_text("training selection_output_id", &self.selection_output_id)?;
370        self.scheduler.validate()?;
371        self.resources.validate(&self.scheduler)?;
372        if !self.refit && self.artifacts.prediction_caches != PredictionCacheRetention::Retain {
373            return contract_error(
374                "training refit=false requires retained prediction caches for REFIT replay"
375                    .to_string(),
376            );
377        }
378        if self.outputs.is_empty() {
379            return contract_error("training options require at least one output".to_string());
380        }
381        let mut previous_id: Option<&str> = None;
382        let mut coordinates = BTreeSet::new();
383        let mut resolved = Vec::with_capacity(self.outputs.len());
384        for output in &self.outputs {
385            if previous_id.is_some_and(|previous| previous >= output.output_id.as_str()) {
386                return contract_error(
387                    "training outputs must be strictly sorted by output_id".to_string(),
388                );
389            }
390            previous_id = Some(output.output_id.as_str());
391            let output = output.resolve(graph)?;
392            if !coordinates.insert((output.node_id.clone(), output.port_name.clone())) {
393                return contract_error(format!(
394                    "training outputs bind `{}.{}` more than once",
395                    output.node_id, output.port_name
396                ));
397            }
398            resolved.push(output);
399        }
400        if !resolved
401            .iter()
402            .any(|output| output.output_id == self.selection_output_id)
403        {
404            return contract_error(format!(
405                "training selection_output_id `{}` does not identify a declared output",
406                self.selection_output_id
407            ));
408        }
409        Ok(resolved)
410    }
411}
412
413/// Content identity paired with one external data requirement.
414#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
415#[serde(deny_unknown_fields)]
416pub struct TrainingDataIdentity {
417    pub requirement_key: String,
418    pub schema_fingerprint: String,
419    pub plan_fingerprint: String,
420    pub relation_fingerprint: String,
421    pub data_content_fingerprint: String,
422    pub target_content_fingerprint: String,
423    pub identity_fingerprint: String,
424}
425
426impl TrainingDataIdentity {
427    /// Build the complete, signed content identity for one exact data binding.
428    ///
429    /// Prediction-only or legacy envelopes may omit content fingerprints, but
430    /// such envelopes cannot attest a native training operation and are
431    /// rejected here.
432    pub fn from_binding_envelope(
433        binding: &DataBinding,
434        envelope: &ExternalDataPlanEnvelope,
435    ) -> Result<Self> {
436        binding.validate_envelope(envelope)?;
437        let requirement_key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
438        let relation_fingerprint = envelope.relation_fingerprint.clone().ok_or_else(|| {
439            DagMlError::CampaignValidation(format!(
440                "external data envelope for `{requirement_key}` cannot attest training without a relation fingerprint"
441            ))
442        })?;
443        let data_content_fingerprint =
444            envelope.data_content_fingerprint.clone().ok_or_else(|| {
445                DagMlError::CampaignValidation(format!(
446                    "external data envelope for `{requirement_key}` cannot attest training without a data content fingerprint"
447                ))
448            })?;
449        let target_content_fingerprint =
450            envelope.target_content_fingerprint.clone().ok_or_else(|| {
451                DagMlError::CampaignValidation(format!(
452                    "external data envelope for `{requirement_key}` cannot attest training without a target content fingerprint"
453                ))
454            })?;
455        let mut identity = Self {
456            requirement_key,
457            schema_fingerprint: envelope.schema_fingerprint.clone(),
458            plan_fingerprint: envelope.plan_fingerprint.clone(),
459            relation_fingerprint,
460            data_content_fingerprint,
461            target_content_fingerprint,
462            identity_fingerprint: zero_fingerprint(),
463        };
464        identity.identity_fingerprint = identity.compute_fingerprint()?;
465        identity.validate()?;
466        Ok(identity)
467    }
468
469    pub fn compute_fingerprint(&self) -> Result<String> {
470        tcv1_fingerprint_without(self, "identity_fingerprint", "training data identity")
471    }
472
473    pub fn validate(&self) -> Result<()> {
474        validate_non_empty("training data requirement_key", &self.requirement_key)?;
475        for (label, value) in [
476            ("training data schema", &self.schema_fingerprint),
477            ("training data plan", &self.plan_fingerprint),
478            ("training data relation", &self.relation_fingerprint),
479            ("training data content", &self.data_content_fingerprint),
480            ("training target content", &self.target_content_fingerprint),
481            ("training data identity", &self.identity_fingerprint),
482        ] {
483            validate_sha256(label, value)?;
484        }
485        if self.identity_fingerprint != self.compute_fingerprint()? {
486            return contract_error(format!(
487                "training data identity `{}` fingerprint does not match TCV1 content",
488                self.requirement_key
489            ));
490        }
491        Ok(())
492    }
493}
494
495#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
496#[serde(rename_all = "snake_case")]
497pub enum ParameterNamespace {
498    Operator,
499    Fit,
500    Control,
501    Structural,
502}
503
504impl ParameterNamespace {
505    /// Return the frozen internal `ExecutionPlan` root for this public wire
506    /// namespace. The mapping is bijective and must not be renamed silently.
507    pub const fn plan_root(self) -> &'static str {
508        match self {
509            Self::Operator => "params",
510            Self::Fit => "fit_params",
511            Self::Control => "control_params",
512            Self::Structural => "structural_params",
513        }
514    }
515}
516
517#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
518#[serde(deny_unknown_fields)]
519pub struct ParameterPatch {
520    pub schema_version: u32,
521    pub node_id: NodeId,
522    pub namespace: ParameterNamespace,
523    pub path: Vec<String>,
524    pub value: serde_json::Value,
525}
526
527impl ParameterPatch {
528    pub fn validate(&self) -> Result<()> {
529        if self.schema_version != PARAMETER_PATCH_SCHEMA_VERSION {
530            return unsupported_version(
531                "parameter patch",
532                self.schema_version,
533                PARAMETER_PATCH_SCHEMA_VERSION,
534            );
535        }
536        if self.path.is_empty() {
537            return contract_error(format!(
538                "parameter patch for `{}` has an empty path",
539                self.node_id
540            ));
541        }
542        for segment in &self.path {
543            if segment.trim().is_empty() || segment == "-" {
544                return contract_error(format!(
545                    "parameter patch for `{}` has an invalid path segment `{segment}`",
546                    self.node_id
547                ));
548            }
549        }
550        Ok(())
551    }
552}
553
554#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
555#[serde(deny_unknown_fields)]
556pub struct NodePatchPolicy {
557    pub node_id: NodeId,
558    pub allowed_namespaces: BTreeSet<ParameterNamespace>,
559}
560
561impl NodePatchPolicy {
562    fn validate(&self) -> Result<()> {
563        if self.allowed_namespaces.is_empty() {
564            return contract_error(format!(
565                "node patch policy `{}` allows no namespaces",
566                self.node_id
567            ));
568        }
569        Ok(())
570    }
571}
572
573#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
574#[serde(deny_unknown_fields)]
575pub struct NamespacedNodeParameters {
576    #[serde(default)]
577    pub params: BTreeMap<String, serde_json::Value>,
578    #[serde(default)]
579    pub fit_params: BTreeMap<String, serde_json::Value>,
580    #[serde(default)]
581    pub control_params: BTreeMap<String, serde_json::Value>,
582    #[serde(default)]
583    pub structural_params: BTreeMap<String, serde_json::Value>,
584}
585
586impl NamespacedNodeParameters {
587    fn namespace_mut(
588        &mut self,
589        namespace: ParameterNamespace,
590    ) -> &mut BTreeMap<String, serde_json::Value> {
591        match namespace {
592            ParameterNamespace::Operator => &mut self.params,
593            ParameterNamespace::Fit => &mut self.fit_params,
594            ParameterNamespace::Control => &mut self.control_params,
595            ParameterNamespace::Structural => &mut self.structural_params,
596        }
597    }
598}
599
600#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
601#[serde(deny_unknown_fields)]
602pub struct ParameterProjection {
603    pub schema_version: u32,
604    pub nodes: BTreeMap<NodeId, NamespacedNodeParameters>,
605    pub requires_recompile: bool,
606    pub structural_patch_count: u32,
607    pub patches_fingerprint: String,
608    pub projection_fingerprint: String,
609}
610
611impl ParameterProjection {
612    pub fn from_json(json: &str) -> Result<Self> {
613        let raw_fingerprint = strict_tcv1_fingerprint_without(
614            json,
615            "projection_fingerprint",
616            "parameter projection",
617        )?;
618        let projection: Self = serde_json::from_str(json)?;
619        if projection.projection_fingerprint != raw_fingerprint {
620            return contract_error(
621                "parameter projection fingerprint does not match original TCV1 JSON".to_string(),
622            );
623        }
624        projection.validate()?;
625        Ok(projection)
626    }
627
628    pub fn compute_fingerprint(&self) -> Result<String> {
629        tcv1_fingerprint_without(self, "projection_fingerprint", "parameter projection")
630    }
631
632    pub fn validate(&self) -> Result<()> {
633        if self.schema_version != PARAMETER_PROJECTION_SCHEMA_VERSION {
634            return unsupported_version(
635                "parameter projection",
636                self.schema_version,
637                PARAMETER_PROJECTION_SCHEMA_VERSION,
638            );
639        }
640        validate_sha256("parameter patches", &self.patches_fingerprint)?;
641        validate_sha256("parameter projection", &self.projection_fingerprint)?;
642        if self.requires_recompile != (self.structural_patch_count > 0) {
643            return contract_error(
644                "parameter projection requires_recompile must equal structural_patch_count>0"
645                    .to_string(),
646            );
647        }
648        if self.projection_fingerprint != self.compute_fingerprint()? {
649            return contract_error(
650                "parameter projection fingerprint does not match TCV1 content".to_string(),
651            );
652        }
653        Ok(())
654    }
655}
656
657/// Clone and deeply apply typed patches. Intermediate path segments must
658/// already exist and be objects; only the final object key may be new. Arrays
659/// are never addressable in V1.
660pub fn project_parameter_patches(
661    plan: &ExecutionPlan,
662    patches: &[ParameterPatch],
663    policies: &[NodePatchPolicy],
664) -> Result<ParameterProjection> {
665    plan.validate()?;
666    validate_canonical_patches(patches)?;
667    let policy_map = validate_patch_policies(plan, policies)?;
668    let patched_nodes = patches
669        .iter()
670        .map(|patch| patch.node_id.clone())
671        .collect::<BTreeSet<_>>();
672    if policy_map.keys().cloned().collect::<BTreeSet<_>>() != patched_nodes {
673        return contract_error(
674            "node patch policies must exactly cover nodes targeted by patches".to_string(),
675        );
676    }
677    let mut nodes = plan
678        .node_plans
679        .iter()
680        .map(|(node_id, node_plan)| {
681            (
682                node_id.clone(),
683                NamespacedNodeParameters {
684                    params: node_plan.params.clone(),
685                    ..NamespacedNodeParameters::default()
686                },
687            )
688        })
689        .collect::<BTreeMap<_, _>>();
690    let mut structural_patch_count = 0_u32;
691    for patch in patches {
692        let policy = policy_map.get(&patch.node_id).ok_or_else(|| {
693            DagMlError::CampaignValidation(format!(
694                "parameter patch for `{}` has no node patch policy",
695                patch.node_id
696            ))
697        })?;
698        if !policy.contains(&patch.namespace) {
699            return contract_error(format!(
700                "parameter namespace `{:?}` is forbidden for node `{}`",
701                patch.namespace, patch.node_id
702            ));
703        }
704        let node = nodes.get_mut(&patch.node_id).ok_or_else(|| {
705            DagMlError::CampaignValidation(format!(
706                "parameter patch references unknown node `{}`",
707                patch.node_id
708            ))
709        })?;
710        deep_set_object_key(
711            node.namespace_mut(patch.namespace),
712            &patch.path,
713            patch.value.clone(),
714            &patch.node_id,
715        )?;
716        if patch.namespace == ParameterNamespace::Structural {
717            structural_patch_count = structural_patch_count.checked_add(1).ok_or_else(|| {
718                DagMlError::CampaignValidation(
719                    "parameter projection has too many structural patches".to_string(),
720                )
721            })?;
722        }
723    }
724    let patches_fingerprint = tcv1_fingerprint(patches, "parameter patches")?;
725    let mut projection = ParameterProjection {
726        schema_version: PARAMETER_PROJECTION_SCHEMA_VERSION,
727        nodes,
728        requires_recompile: structural_patch_count > 0,
729        structural_patch_count,
730        patches_fingerprint,
731        projection_fingerprint: zero_fingerprint(),
732    };
733    projection.projection_fingerprint = projection.compute_fingerprint()?;
734    projection.validate()?;
735    Ok(projection)
736}
737
738#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
739#[serde(rename_all = "snake_case")]
740pub enum TrainingInfluenceKind {
741    TransformFit,
742    ModelFit,
743    HpoSelection,
744    EarlyStopping,
745    WeightingResampling,
746    TrainedMetaAggregation,
747}
748
749#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
750#[serde(deny_unknown_fields)]
751pub struct ControllerInfluenceRequirement {
752    pub node_id: NodeId,
753    pub kind: TrainingInfluenceKind,
754    pub scope_id: String,
755    pub phase: Phase,
756    #[serde(deserialize_with = "deserialize_required_nullable")]
757    pub fold_id: Option<FoldId>,
758    pub physical_sample_ids: Vec<SampleId>,
759}
760
761#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
762#[serde(deny_unknown_fields)]
763pub struct TrainingInfluenceEntry {
764    pub kind: TrainingInfluenceKind,
765    pub scope_id: String,
766    #[serde(deserialize_with = "deserialize_required_nullable")]
767    pub node_id: Option<NodeId>,
768    pub physical_sample_ids: Vec<SampleId>,
769    pub origin_sample_ids: Vec<SampleId>,
770    pub group_ids: Vec<GroupId>,
771}
772
773impl TrainingInfluenceEntry {
774    fn validate(&self) -> Result<()> {
775        validate_identifier_text("training influence scope_id", &self.scope_id)?;
776        validate_sorted_unique_ids(
777            "training influence physical_sample_ids",
778            &self.physical_sample_ids,
779            true,
780        )?;
781        validate_sorted_unique_ids(
782            "training influence origin_sample_ids",
783            &self.origin_sample_ids,
784            false,
785        )?;
786        validate_sorted_unique_ids("training influence group_ids", &self.group_ids, false)
787    }
788}
789
790#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
791#[serde(deny_unknown_fields)]
792pub struct TrainingInfluenceManifest {
793    pub schema_version: u32,
794    pub relation_fingerprint: String,
795    pub entries: Vec<TrainingInfluenceEntry>,
796    pub manifest_fingerprint: String,
797}
798
799impl TrainingInfluenceManifest {
800    pub fn compute_fingerprint(&self) -> Result<String> {
801        tcv1_fingerprint_without(self, "manifest_fingerprint", "training influence manifest")
802    }
803
804    pub fn derive_for_projection(
805        projection: &TrainingContractProjection,
806        request: &TrainingRequest,
807        relations: &SampleRelationSet,
808    ) -> Result<Self> {
809        projection.validate()?;
810        relations.validate()?;
811        let relation_fingerprint = relations.fingerprint()?;
812        if request
813            .data_identities
814            .iter()
815            .any(|identity| identity.relation_fingerprint != relation_fingerprint)
816        {
817            return contract_error(
818                "training data identities do not all bind the influence relation".to_string(),
819            );
820        }
821        let expected = expected_influence_coordinates(
822            request,
823            &projection.plan,
824            &projection.predictor_node_ids,
825        )?;
826        let mut entries = Vec::with_capacity(expected.len());
827        for ((kind, scope_id, node_id), samples) in expected {
828            let (origin_sample_ids, group_ids) =
829                influence_identity_closure_for_samples(&scope_id, &samples, relations)?;
830            entries.push(TrainingInfluenceEntry {
831                kind,
832                scope_id,
833                node_id,
834                physical_sample_ids: samples.into_iter().collect(),
835                origin_sample_ids,
836                group_ids,
837            });
838        }
839        let mut manifest = Self {
840            schema_version: TRAINING_INFLUENCE_MANIFEST_SCHEMA_VERSION,
841            relation_fingerprint,
842            entries,
843            manifest_fingerprint: zero_fingerprint(),
844        };
845        manifest.manifest_fingerprint = manifest.compute_fingerprint()?;
846        manifest.validate_for_projection(projection, request, relations)?;
847        Ok(manifest)
848    }
849
850    pub fn validate(&self) -> Result<()> {
851        if self.schema_version != TRAINING_INFLUENCE_MANIFEST_SCHEMA_VERSION {
852            return unsupported_version(
853                "training influence manifest",
854                self.schema_version,
855                TRAINING_INFLUENCE_MANIFEST_SCHEMA_VERSION,
856            );
857        }
858        validate_sha256("training influence relation", &self.relation_fingerprint)?;
859        validate_sha256("training influence manifest", &self.manifest_fingerprint)?;
860        if self.entries.is_empty() {
861            return contract_error(
862                "training influence manifest requires at least one entry".to_string(),
863            );
864        }
865        let mut previous: Option<(TrainingInfluenceKind, &str, Option<&NodeId>)> = None;
866        for entry in &self.entries {
867            entry.validate()?;
868            let key = (entry.kind, entry.scope_id.as_str(), entry.node_id.as_ref());
869            if previous.as_ref().is_some_and(|previous| previous >= &key) {
870                return contract_error(
871                    "training influence entries must be strictly canonically sorted".to_string(),
872                );
873            }
874            previous = Some(key);
875        }
876        if self.manifest_fingerprint != self.compute_fingerprint()? {
877            return contract_error(
878                "training influence manifest fingerprint does not match TCV1 content".to_string(),
879            );
880        }
881        Ok(())
882    }
883
884    pub fn validate_for_projection(
885        &self,
886        projection: &TrainingContractProjection,
887        request: &TrainingRequest,
888        relations: &SampleRelationSet,
889    ) -> Result<()> {
890        self.validate()?;
891        relations.validate()?;
892        let relation_fingerprint = relations.fingerprint()?;
893        if self.relation_fingerprint != relation_fingerprint {
894            return contract_error(
895                "training influence relation fingerprint does not match relation set".to_string(),
896            );
897        }
898        if request
899            .data_identities
900            .iter()
901            .any(|identity| identity.relation_fingerprint != relation_fingerprint)
902        {
903            return contract_error(
904                "training data identities do not all bind the influence relation".to_string(),
905            );
906        }
907
908        let expected = expected_influence_coordinates(
909            request,
910            &projection.plan,
911            &projection.predictor_node_ids,
912        )?;
913        let mut actual = BTreeSet::new();
914        for entry in &self.entries {
915            if let Some(node_id) = &entry.node_id {
916                if !projection.predictor_node_ids.contains(node_id) {
917                    return contract_error(format!(
918                        "training influence node `{node_id}` is outside predictor closure"
919                    ));
920                }
921            }
922            let coordinate = (entry.kind, entry.scope_id.clone(), entry.node_id.clone());
923            let expected_samples = expected.get(&coordinate).ok_or_else(|| {
924                DagMlError::CampaignValidation(format!(
925                    "training influence contains undeclared coordinate `{:?}/{}/{:?}`",
926                    entry.kind, entry.scope_id, entry.node_id
927                ))
928            })?;
929            if entry
930                .physical_sample_ids
931                .iter()
932                .cloned()
933                .collect::<BTreeSet<_>>()
934                != *expected_samples
935            {
936                return contract_error(format!(
937                    "training influence coordinate `{:?}/{}/{:?}` does not contain the exact scope samples",
938                    entry.kind, entry.scope_id, entry.node_id
939                ));
940            }
941            validate_influence_identity_closure(entry, relations)?;
942            actual.insert(coordinate);
943        }
944        let expected_keys = expected.into_keys().collect::<BTreeSet<_>>();
945        if actual != expected_keys {
946            return contract_error(
947                "training influence entries do not exactly cover capability-derived phase scopes"
948                    .to_string(),
949            );
950        }
951        Ok(())
952    }
953}
954
955#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
956#[serde(deny_unknown_fields)]
957pub struct TrainingRequest {
958    pub schema_version: u32,
959    pub request_id: String,
960    pub plan_id: String,
961    pub graph: GraphSpec,
962    pub campaign: CampaignSpec,
963    pub controller_manifests: Vec<ControllerManifest>,
964    pub data_identities: Vec<TrainingDataIdentity>,
965    pub parameter_patches: Vec<ParameterPatch>,
966    pub patch_policies: Vec<NodePatchPolicy>,
967    pub influence_requirements: Vec<ControllerInfluenceRequirement>,
968    /// Native loss roles are attested in the request and lowered into the
969    /// execution plan before controller tasks are constructed.  They remain
970    /// optional for existing V1 requests, whose canonical meaning is no
971    /// process-local training loss.
972    #[serde(default, skip_serializing_if = "Vec::is_empty")]
973    pub training_losses: Vec<TrainingLossRoleReference>,
974    pub options: TrainingOptions,
975    pub request_fingerprint: String,
976}
977
978#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
979#[serde(deny_unknown_fields)]
980pub struct TrainingContractProjection {
981    pub request_id: String,
982    pub request_fingerprint: String,
983    pub plan: ExecutionPlan,
984    pub outputs: Vec<ResolvedTrainingOutput>,
985    pub predictor_node_ids: BTreeSet<NodeId>,
986    pub parameters: ParameterProjection,
987}
988
989impl TrainingContractProjection {
990    pub fn from_json(json: &str) -> Result<Self> {
991        parse_typed_json(json).map_err(|error| {
992            DagMlError::RuntimeValidation(format!(
993                "training contract projection is outside strict TCV1 JSON: {error}"
994            ))
995        })?;
996        let mut deserializer = serde_json::Deserializer::from_str(json);
997        let mut ignored_paths = Vec::new();
998        let projection: Self = serde_ignored::deserialize(&mut deserializer, |path| {
999            ignored_paths.push(path.to_string());
1000        })?;
1001        if !ignored_paths.is_empty() {
1002            ignored_paths.sort();
1003            ignored_paths.dedup();
1004            return contract_error(format!(
1005                "training contract projection contains unknown field(s) at: {}",
1006                ignored_paths.join(", ")
1007            ));
1008        }
1009        projection.validate()?;
1010        Ok(projection)
1011    }
1012
1013    pub fn validate(&self) -> Result<()> {
1014        validate_identifier_text("training projection request_id", &self.request_id)?;
1015        validate_sha256(
1016            "training projection request_fingerprint",
1017            &self.request_fingerprint,
1018        )?;
1019        self.plan.validate()?;
1020        self.parameters.validate()?;
1021        if self.parameters.nodes.keys().collect::<BTreeSet<_>>()
1022            != self.plan.node_plans.keys().collect::<BTreeSet<_>>()
1023        {
1024            return contract_error(
1025                "training projection parameter nodes do not exactly match execution plan"
1026                    .to_string(),
1027            );
1028        }
1029        if self.outputs.is_empty() {
1030            return contract_error("training projection requires at least one output".to_string());
1031        }
1032        let mut previous_id: Option<&str> = None;
1033        let mut coordinates = BTreeSet::new();
1034        for output in &self.outputs {
1035            if previous_id.is_some_and(|previous| previous >= output.output_id.as_str()) {
1036                return contract_error(
1037                    "training projection outputs must be strictly sorted by output_id".to_string(),
1038                );
1039            }
1040            previous_id = Some(output.output_id.as_str());
1041            let requested = TrainingOutputRequest {
1042                output_id: output.output_id.clone(),
1043                node_id: output.node_id.clone(),
1044                port_name: Some(output.port_name.clone()),
1045                prediction_level: output.prediction_level,
1046                unit_level: output.unit_level,
1047                prediction_kind: output.prediction_kind,
1048                target_names: output.target_names.clone(),
1049                target_units: output.target_units.clone(),
1050                class_labels: output.class_labels.clone(),
1051                output_order: output.output_order,
1052                target_space: output.target_space.clone(),
1053            };
1054            if requested.resolve(&self.plan.graph_plan.graph)? != *output {
1055                return contract_error(
1056                    "training projection contains a non-canonical resolved output".to_string(),
1057                );
1058            }
1059            if !coordinates.insert((output.node_id.clone(), output.port_name.clone())) {
1060                return contract_error(
1061                    "training projection contains duplicate output coordinates".to_string(),
1062                );
1063            }
1064        }
1065        let expected_closure = predictor_closure(
1066            &self.plan,
1067            self.outputs.iter().map(|output| &output.node_id),
1068        )?;
1069        if self.predictor_node_ids != expected_closure {
1070            return contract_error(
1071                "training projection predictor_node_ids do not match output closure".to_string(),
1072            );
1073        }
1074        Ok(())
1075    }
1076}
1077
1078impl TrainingRequest {
1079    pub fn from_json(json: &str) -> Result<Self> {
1080        let raw_fingerprint =
1081            strict_tcv1_fingerprint_without(json, "request_fingerprint", "training request")?;
1082        let request: Self = serde_json::from_str(json)?;
1083        if request.request_fingerprint != raw_fingerprint {
1084            return contract_error(
1085                "training request fingerprint does not match original TCV1 JSON".to_string(),
1086            );
1087        }
1088        request.validate()?;
1089        Ok(request)
1090    }
1091
1092    pub fn compute_fingerprint(&self) -> Result<String> {
1093        tcv1_fingerprint_without(self, "request_fingerprint", "training request")
1094    }
1095
1096    pub fn validate(&self) -> Result<()> {
1097        self.project().map(|_| ())
1098    }
1099
1100    pub fn project(&self) -> Result<TrainingContractProjection> {
1101        if self.schema_version != TRAINING_REQUEST_SCHEMA_VERSION {
1102            return unsupported_version(
1103                "training request",
1104                self.schema_version,
1105                TRAINING_REQUEST_SCHEMA_VERSION,
1106            );
1107        }
1108        validate_identifier_text("training request_id", &self.request_id)?;
1109        validate_non_empty("training plan_id", &self.plan_id)?;
1110        self.graph.validate()?;
1111        self.campaign.validate()?;
1112        if self.campaign.root_seed != Some(self.options.seed) {
1113            return contract_error(
1114                "training options seed must exactly match campaign.root_seed".to_string(),
1115            );
1116        }
1117        validate_sha256("training request", &self.request_fingerprint)?;
1118        if self.request_fingerprint != self.compute_fingerprint()? {
1119            return contract_error(
1120                "training request fingerprint does not match TCV1 content".to_string(),
1121            );
1122        }
1123        let outputs = self.options.validate(&self.graph)?;
1124        let mut registry = ControllerRegistry::new();
1125        let mut previous_controller: Option<&str> = None;
1126        for manifest in &self.controller_manifests {
1127            if previous_controller
1128                .is_some_and(|previous| previous >= manifest.controller_id.as_str())
1129            {
1130                return contract_error(
1131                    "training controller_manifests must be strictly sorted by controller_id"
1132                        .to_string(),
1133                );
1134            }
1135            previous_controller = Some(manifest.controller_id.as_str());
1136            registry.register(manifest.clone())?;
1137        }
1138        let plan = build_execution_plan(
1139            self.plan_id.clone(),
1140            self.graph.clone(),
1141            self.campaign.clone(),
1142            &registry,
1143        )?
1144        .with_training_losses(self.training_losses.clone())?;
1145        validate_output_controllers(&plan, &outputs)?;
1146        validate_selection_output(&plan, &self.options, &outputs)?;
1147        validate_training_data_identities(self, &plan)?;
1148        let parameters =
1149            project_parameter_patches(&plan, &self.parameter_patches, &self.patch_policies)?;
1150        let predictor_node_ids =
1151            predictor_closure(&plan, outputs.iter().map(|output| &output.node_id))?;
1152        validate_scheduler_capabilities(&self.options.scheduler, &plan, &predictor_node_ids)?;
1153        validate_artifact_mode(&self.options.artifacts, &plan, &predictor_node_ids)?;
1154        validate_influence_requirements(self, &plan, &predictor_node_ids)?;
1155        let projection = TrainingContractProjection {
1156            request_id: self.request_id.clone(),
1157            request_fingerprint: self.request_fingerprint.clone(),
1158            plan,
1159            outputs,
1160            predictor_node_ids,
1161            parameters,
1162        };
1163        projection.validate()?;
1164        Ok(projection)
1165    }
1166}
1167
1168/// Candidate-cache identity. Every field that can change predictions is part
1169/// of the TCV1 namespace; a requirement key alone is deliberately insufficient.
1170#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1171#[serde(deny_unknown_fields)]
1172pub struct CacheNamespace {
1173    pub schema_version: u32,
1174    pub prediction_requirement_key: String,
1175    pub data_requirement_key: String,
1176    pub producer_node_id: NodeId,
1177    pub source_port_name: String,
1178    pub consumer_node_id: NodeId,
1179    pub target_port_name: String,
1180    pub phase: Phase,
1181    pub params_fingerprint: String,
1182    pub data_identity_fingerprint: String,
1183    pub fold_id: FoldId,
1184    pub trial_id: String,
1185    pub seed: u64,
1186    pub namespace_fingerprint: String,
1187}
1188
1189impl CacheNamespace {
1190    #[allow(clippy::too_many_arguments)]
1191    pub fn new(
1192        prediction_requirement_key: String,
1193        data_requirement_key: String,
1194        producer_node_id: NodeId,
1195        source_port_name: String,
1196        consumer_node_id: NodeId,
1197        target_port_name: String,
1198        params_fingerprint: String,
1199        data_identity_fingerprint: String,
1200        fold_id: FoldId,
1201        trial_id: String,
1202        seed: u64,
1203    ) -> Result<Self> {
1204        let mut namespace = Self {
1205            schema_version: CACHE_NAMESPACE_SCHEMA_VERSION,
1206            prediction_requirement_key,
1207            data_requirement_key,
1208            producer_node_id,
1209            source_port_name,
1210            consumer_node_id,
1211            target_port_name,
1212            phase: Phase::FitCv,
1213            params_fingerprint,
1214            data_identity_fingerprint,
1215            fold_id,
1216            trial_id,
1217            seed,
1218            namespace_fingerprint: zero_fingerprint(),
1219        };
1220        namespace.namespace_fingerprint = namespace.compute_fingerprint()?;
1221        namespace.validate()?;
1222        Ok(namespace)
1223    }
1224
1225    pub fn from_json(json: &str) -> Result<Self> {
1226        let raw_fingerprint =
1227            strict_tcv1_fingerprint_without(json, "namespace_fingerprint", "cache namespace")?;
1228        let namespace: Self = serde_json::from_str(json)?;
1229        if namespace.namespace_fingerprint != raw_fingerprint {
1230            return contract_error(
1231                "cache namespace fingerprint does not match original TCV1 JSON".to_string(),
1232            );
1233        }
1234        namespace.validate()?;
1235        Ok(namespace)
1236    }
1237
1238    pub fn compute_fingerprint(&self) -> Result<String> {
1239        tcv1_fingerprint_without(self, "namespace_fingerprint", "cache namespace")
1240    }
1241
1242    pub fn validate(&self) -> Result<()> {
1243        if self.schema_version != CACHE_NAMESPACE_SCHEMA_VERSION {
1244            return unsupported_version(
1245                "cache namespace",
1246                self.schema_version,
1247                CACHE_NAMESPACE_SCHEMA_VERSION,
1248            );
1249        }
1250        validate_non_empty(
1251            "cache namespace prediction_requirement_key",
1252            &self.prediction_requirement_key,
1253        )?;
1254        validate_non_empty(
1255            "cache namespace data_requirement_key",
1256            &self.data_requirement_key,
1257        )?;
1258        validate_non_empty("cache namespace source_port_name", &self.source_port_name)?;
1259        validate_non_empty("cache namespace target_port_name", &self.target_port_name)?;
1260        if self.phase != Phase::FitCv {
1261            return contract_error(
1262                "cache namespace V1 is fold-scoped and permits only FIT_CV".to_string(),
1263            );
1264        }
1265        let expected_requirement_key = bundle_prediction_requirement_key(
1266            &self.producer_node_id,
1267            &self.source_port_name,
1268            &self.consumer_node_id,
1269            &self.target_port_name,
1270        );
1271        if self.prediction_requirement_key != expected_requirement_key {
1272            return contract_error(
1273                "cache namespace requirement_key does not match producer/source/consumer/target coordinates"
1274                    .to_string(),
1275            );
1276        }
1277        validate_identifier_text("cache namespace trial_id", &self.trial_id)?;
1278        for (label, fingerprint) in [
1279            ("cache params", &self.params_fingerprint),
1280            ("cache data identity", &self.data_identity_fingerprint),
1281            ("cache namespace", &self.namespace_fingerprint),
1282        ] {
1283            validate_sha256(label, fingerprint)?;
1284        }
1285        if self.namespace_fingerprint != self.compute_fingerprint()? {
1286            return contract_error(
1287                "cache namespace fingerprint does not match TCV1 content".to_string(),
1288            );
1289        }
1290        Ok(())
1291    }
1292
1293    pub fn validate_for_identity(&self, identity: &TrainingDataIdentity) -> Result<()> {
1294        self.validate()?;
1295        identity.validate()?;
1296        if self.data_requirement_key != identity.requirement_key
1297            || self.data_identity_fingerprint != identity.identity_fingerprint
1298        {
1299            return contract_error(
1300                "cache namespace does not bind the complete training data identity".to_string(),
1301            );
1302        }
1303        Ok(())
1304    }
1305}
1306
1307/// A resolved W0 OutputBinding with native port validation.
1308#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1309#[serde(deny_unknown_fields)]
1310pub struct OutputBinding {
1311    pub schema_version: u32,
1312    pub binding_id: String,
1313    pub node_id: NodeId,
1314    pub port_name: String,
1315    pub prediction_level: PredictionLevel,
1316    #[serde(default)]
1317    pub unit_level: Option<EntityUnitLevel>,
1318    pub prediction_kind: PredictionKind,
1319    pub prediction_source: PredictionSource,
1320    #[serde(default)]
1321    pub refit_strategy: Option<RefitStrategy>,
1322    pub aggregation_fingerprint: String,
1323    pub target_names: Vec<String>,
1324    pub target_units: Vec<Option<String>>,
1325    pub class_labels: Vec<Vec<String>>,
1326    pub output_order: OutputOrder,
1327    pub target_space: String,
1328    pub binding_fingerprint: String,
1329}
1330
1331impl OutputBinding {
1332    pub fn compute_fingerprint(&self) -> Result<String> {
1333        tcv1_fingerprint_without(self, "binding_fingerprint", "output binding")
1334    }
1335
1336    pub fn validate(&self, graph: &GraphSpec) -> Result<()> {
1337        if self.schema_version != OUTPUT_BINDING_SCHEMA_VERSION {
1338            return unsupported_version(
1339                "output binding",
1340                self.schema_version,
1341                OUTPUT_BINDING_SCHEMA_VERSION,
1342            );
1343        }
1344        validate_identifier_text("output binding_id", &self.binding_id)?;
1345        validate_non_empty("output binding port_name", &self.port_name)?;
1346        validate_sha256("output aggregation", &self.aggregation_fingerprint)?;
1347        validate_sha256("output binding", &self.binding_fingerprint)?;
1348        validate_output_unit_level(self.prediction_level, self.unit_level)?;
1349        validate_output_shape(
1350            self.prediction_kind,
1351            self.output_order,
1352            &self.target_names,
1353            &self.target_units,
1354            &self.class_labels,
1355            &self.target_space,
1356        )?;
1357        match (self.prediction_source, self.refit_strategy) {
1358            (PredictionSource::FinalRefit, None) => {
1359                return contract_error(
1360                    "final_refit output binding requires refit_strategy".to_string(),
1361                );
1362            }
1363            (PredictionSource::CvEnsemble | PredictionSource::FoldMember, Some(_)) => {
1364                return contract_error(
1365                    "non-final output binding forbids refit_strategy".to_string(),
1366                );
1367            }
1368            _ => {}
1369        }
1370        let request = TrainingOutputRequest {
1371            output_id: self.binding_id.clone(),
1372            node_id: self.node_id.clone(),
1373            port_name: Some(self.port_name.clone()),
1374            prediction_level: self.prediction_level,
1375            unit_level: self.unit_level,
1376            prediction_kind: self.prediction_kind,
1377            target_names: self.target_names.clone(),
1378            target_units: self.target_units.clone(),
1379            class_labels: self.class_labels.clone(),
1380            output_order: self.output_order,
1381            target_space: self.target_space.clone(),
1382        };
1383        request.resolve(graph)?;
1384        if self.binding_fingerprint != self.compute_fingerprint()? {
1385            return contract_error(
1386                "output binding fingerprint does not match TCV1 content".to_string(),
1387            );
1388        }
1389        Ok(())
1390    }
1391}
1392
1393#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1394#[serde(deny_unknown_fields)]
1395pub struct PredictorTemplate {
1396    pub graph: GraphSpec,
1397    pub campaign: CampaignSpec,
1398    pub controller_manifests: BTreeMap<crate::ids::ControllerId, ControllerManifest>,
1399    pub template_fingerprint: String,
1400}
1401
1402impl PredictorTemplate {
1403    pub fn compute_fingerprint(&self) -> Result<String> {
1404        tcv1_fingerprint_without(self, "template_fingerprint", "predictor template")
1405    }
1406
1407    pub fn validate(&self) -> Result<()> {
1408        self.graph.validate()?;
1409        self.campaign.validate()?;
1410        for (controller_id, manifest) in &self.controller_manifests {
1411            if controller_id != &manifest.controller_id {
1412                return contract_error(format!(
1413                    "predictor template controller key `{controller_id}` does not match manifest `{}`",
1414                    manifest.controller_id
1415                ));
1416            }
1417            manifest.validate()?;
1418        }
1419        validate_sha256("predictor template", &self.template_fingerprint)?;
1420        if self.template_fingerprint != self.compute_fingerprint()? {
1421            return contract_error(
1422                "predictor template fingerprint does not match TCV1 content".to_string(),
1423            );
1424        }
1425        Ok(())
1426    }
1427}
1428
1429#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1430#[serde(deny_unknown_fields)]
1431pub struct TrainingOutcomeRef {
1432    pub outcome_id: String,
1433    pub outcome_fingerprint: String,
1434    /// Non-recursive source identity for outcome-owned conformal state. It is
1435    /// absent before calibration and retains the exact calibration-free
1436    /// TrainingOutcome fingerprint after attachment.
1437    #[serde(default, skip_serializing_if = "Option::is_none")]
1438    pub pre_conformal_outcome_fingerprint: Option<String>,
1439    pub training_request_fingerprint: String,
1440    pub effective_plan_fingerprint: String,
1441    pub execution_bundle_id: BundleId,
1442    pub execution_bundle_fingerprint: String,
1443    pub output_binding_fingerprints: Vec<String>,
1444    pub training_influence_fingerprint: String,
1445    pub data_identities_fingerprint: String,
1446}
1447
1448impl TrainingOutcomeRef {
1449    pub(crate) fn validate(&self) -> Result<()> {
1450        validate_identifier_text("training outcome_id", &self.outcome_id)?;
1451        for (label, fingerprint) in [
1452            ("training outcome", &self.outcome_fingerprint),
1453            (
1454                "training outcome request",
1455                &self.training_request_fingerprint,
1456            ),
1457            (
1458                "training outcome effective plan",
1459                &self.effective_plan_fingerprint,
1460            ),
1461            (
1462                "training outcome influence",
1463                &self.training_influence_fingerprint,
1464            ),
1465            (
1466                "training outcome execution bundle",
1467                &self.execution_bundle_fingerprint,
1468            ),
1469            (
1470                "training outcome data identities",
1471                &self.data_identities_fingerprint,
1472            ),
1473        ] {
1474            validate_sha256(label, fingerprint)?;
1475        }
1476        if let Some(fingerprint) = &self.pre_conformal_outcome_fingerprint {
1477            validate_sha256("pre-conformal training outcome", fingerprint)?;
1478        }
1479        if self.output_binding_fingerprints.is_empty() {
1480            return contract_error(
1481                "training outcome reference requires output binding fingerprints".to_string(),
1482            );
1483        }
1484        for fingerprint in &self.output_binding_fingerprints {
1485            validate_sha256("training outcome output binding", fingerprint)?;
1486        }
1487        Ok(())
1488    }
1489}
1490
1491#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
1492#[serde(rename_all = "snake_case")]
1493pub enum ArtifactLoadMode {
1494    NativePortable,
1495    HostSidecar,
1496}
1497
1498#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1499#[serde(deny_unknown_fields)]
1500pub struct PackageArtifactBinding {
1501    pub artifact_id: ArtifactId,
1502    pub load_mode: ArtifactLoadMode,
1503}
1504
1505/// Portable deployment package. It contains only JSON-safe contracts and
1506/// artifact descriptors; process-local handles live exclusively in
1507/// [`LoadedPredictor`].
1508#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1509#[serde(deny_unknown_fields)]
1510pub struct PortablePredictorPackage {
1511    pub schema_version: u32,
1512    pub package_id: String,
1513    pub template: PredictorTemplate,
1514    pub training_request_fingerprint: String,
1515    pub training_outcome: TrainingOutcomeRef,
1516    pub effective_plan: ExecutionPlan,
1517    pub execution_bundle: ExecutionBundle,
1518    /// Complete native calibration state for standalone package replay.  The
1519    /// nested execution bundle carries the matching typed reference.
1520    #[serde(default, skip_serializing_if = "Option::is_none")]
1521    pub conformal_calibration: Option<ConformalCalibration>,
1522    /// Complete pre-calibration PREDICT replay evidence retained by V2.
1523    #[serde(default, skip_serializing_if = "Option::is_none")]
1524    pub conformal_calibration_replay: Option<TrainingReplayOutcome>,
1525    pub output_bindings: Vec<OutputBinding>,
1526    pub predictor_node_ids: Vec<NodeId>,
1527    pub training_influence: TrainingInfluenceManifest,
1528    pub data_identities: Vec<TrainingDataIdentity>,
1529    pub fitted_artifact_mode: FittedArtifactMode,
1530    pub artifact_bindings: Vec<PackageArtifactBinding>,
1531    pub package_fingerprint: String,
1532}
1533
1534impl PortablePredictorPackage {
1535    pub fn compute_fingerprint(&self) -> Result<String> {
1536        tcv1_fingerprint_without(self, "package_fingerprint", "portable predictor package")
1537    }
1538
1539    pub fn from_json(json: &str) -> Result<Self> {
1540        let raw_fingerprint = strict_tcv1_fingerprint_without(
1541            json,
1542            "package_fingerprint",
1543            "portable predictor package",
1544        )?;
1545        let package: Self = serde_json::from_str(json)?;
1546        if package.package_fingerprint != raw_fingerprint {
1547            return contract_error(
1548                "portable predictor package fingerprint does not match original TCV1 JSON"
1549                    .to_string(),
1550            );
1551        }
1552        package.validate()?;
1553        Ok(package)
1554    }
1555
1556    pub fn validate(&self) -> Result<()> {
1557        if self.schema_version < MIN_READABLE_PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION
1558            || self.schema_version > PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION
1559        {
1560            return unsupported_version(
1561                "portable predictor package",
1562                self.schema_version,
1563                PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION,
1564            );
1565        }
1566        if self.schema_version == LEGACY_PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION
1567            && (self.conformal_calibration.is_some() || self.conformal_calibration_replay.is_some())
1568        {
1569            return contract_error(
1570                "portable predictor package V1 cannot carry conformal state; migrate to a published V2 package schema".to_string(),
1571            );
1572        }
1573        validate_identifier_text("portable predictor package_id", &self.package_id)?;
1574        validate_sha256(
1575            "portable predictor training request",
1576            &self.training_request_fingerprint,
1577        )?;
1578        validate_sha256("portable predictor package", &self.package_fingerprint)?;
1579        self.training_outcome.validate()?;
1580        if self.training_request_fingerprint != self.training_outcome.training_request_fingerprint {
1581            return contract_error(
1582                "portable predictor request fingerprint is not cross-linked by outcome reference"
1583                    .to_string(),
1584            );
1585        }
1586        self.template.validate()?;
1587        self.effective_plan.validate()?;
1588        if self.template.graph != self.effective_plan.graph_plan.graph
1589            || self.template.campaign != self.effective_plan.campaign
1590            || self.template.controller_manifests != self.effective_plan.controller_manifests
1591        {
1592            return contract_error(
1593                "portable predictor template does not exactly match effective plan".to_string(),
1594            );
1595        }
1596        self.execution_bundle
1597            .validate_against_plan(&self.effective_plan)?;
1598        if self.schema_version == PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION
1599            && self.execution_bundle.schema_version
1600                != crate::bundle::EXECUTION_BUNDLE_SCHEMA_VERSION
1601        {
1602            return contract_error(
1603                "portable predictor package V2 requires an execution bundle V2".to_string(),
1604            );
1605        }
1606        match (
1607            &self.conformal_calibration,
1608            &self.conformal_calibration_replay,
1609            &self.execution_bundle.conformal_calibration,
1610        ) {
1611            (Some(calibration), Some(replay), Some(reference)) => {
1612                reference.validate_against(calibration)?;
1613                calibration.validate()?;
1614                replay.validate()?;
1615                let binding = self
1616                    .output_bindings
1617                    .iter()
1618                    .find(|binding| binding.binding_id == calibration.binding_id)
1619                    .ok_or_else(|| {
1620                        DagMlError::RuntimeValidation(
1621                            "portable predictor conformal binding is absent".to_string(),
1622                        )
1623                    })?;
1624                let fold_set = self.effective_plan.fold_set.as_ref().ok_or_else(|| {
1625                    DagMlError::RuntimeValidation(
1626                        "portable predictor conformal calibration requires FoldSet".to_string(),
1627                    )
1628                })?;
1629                let context = &calibration.context;
1630                let mut pre_conformal_bundle = self.execution_bundle.clone();
1631                pre_conformal_bundle.conformal_calibration = None;
1632                let pre_conformal_outcome_fingerprint = self
1633                    .training_outcome
1634                    .pre_conformal_outcome_fingerprint
1635                    .as_ref()
1636                    .ok_or_else(|| {
1637                        DagMlError::RuntimeValidation(
1638                            "portable predictor conformal state has no pre-conformal source anchor"
1639                                .to_string(),
1640                        )
1641                    })?;
1642                let expected_replay_source = TrainingOutcomeRef {
1643                    outcome_id: self.training_outcome.outcome_id.clone(),
1644                    outcome_fingerprint: pre_conformal_outcome_fingerprint.clone(),
1645                    pre_conformal_outcome_fingerprint: None,
1646                    training_request_fingerprint: self
1647                        .training_outcome
1648                        .training_request_fingerprint
1649                        .clone(),
1650                    effective_plan_fingerprint: self
1651                        .training_outcome
1652                        .effective_plan_fingerprint
1653                        .clone(),
1654                    execution_bundle_id: self.training_outcome.execution_bundle_id.clone(),
1655                    execution_bundle_fingerprint: tcv1_fingerprint(
1656                        &pre_conformal_bundle,
1657                        "portable predictor pre-conformal execution bundle",
1658                    )?,
1659                    output_binding_fingerprints: self
1660                        .training_outcome
1661                        .output_binding_fingerprints
1662                        .clone(),
1663                    training_influence_fingerprint: self
1664                        .training_outcome
1665                        .training_influence_fingerprint
1666                        .clone(),
1667                    data_identities_fingerprint: self
1668                        .training_outcome
1669                        .data_identities_fingerprint
1670                        .clone(),
1671                };
1672                let replay_request = replay_request_from_outcome(replay);
1673                replay_request.validate()?;
1674                if context.predictor_binding_fingerprint != binding.binding_fingerprint
1675                    || self
1676                        .training_outcome
1677                        .pre_conformal_outcome_fingerprint
1678                        .as_deref()
1679                        != Some(context.source_training_outcome_fingerprint.as_str())
1680                    || context.data_identities_fingerprint
1681                        != self.training_outcome.data_identities_fingerprint
1682                    || context.training_influence_fingerprint
1683                        != self.training_influence.manifest_fingerprint
1684                    || context.fold_set_fingerprint != fold_set_fingerprint(fold_set)?
1685                    || context.calibration_replay_outcome_fingerprint != replay.outcome_fingerprint
1686                    || replay.source_training_outcome != expected_replay_source
1687                    || replay_request.source_outcome_fingerprint
1688                        != *pre_conformal_outcome_fingerprint
1689                    || replay.replay_request_id != replay_request.request_id
1690                    || replay.phase != Phase::Predict
1691                    || replay.bundle_id != self.execution_bundle.bundle_id
1692                    || replay.plan_id != self.effective_plan.id
1693                {
1694                    return contract_error("portable predictor conformal context does not exactly cross-link package provenance".to_string());
1695                }
1696                if context.relation_fingerprint == self.training_influence.relation_fingerprint {
1697                    return contract_error(
1698                        "portable predictor calibration relation authority must be distinct from development relations"
1699                            .to_string(),
1700                    );
1701                }
1702                let package_bindings = self
1703                    .output_bindings
1704                    .iter()
1705                    .map(|binding| (binding.binding_id.as_str(), binding))
1706                    .collect::<BTreeMap<_, _>>();
1707                for replay_output in &replay.outputs {
1708                    let package_binding = package_bindings
1709                        .get(replay_output.binding.binding_id.as_str())
1710                        .ok_or_else(|| {
1711                            DagMlError::RuntimeValidation(
1712                                "portable predictor calibration replay contains an output binding absent from the package"
1713                                    .to_string(),
1714                            )
1715                        })?;
1716                    if &replay_output.binding != *package_binding {
1717                        return contract_error(
1718                            "portable predictor calibration replay output binding does not exactly match the package"
1719                                .to_string(),
1720                        );
1721                    }
1722                    replay_output.validate(&self.effective_plan)?;
1723                }
1724                let replay_output = replay
1725                    .outputs
1726                    .iter()
1727                    .find(|output| output.binding.binding_id == calibration.binding_id)
1728                    .ok_or_else(|| {
1729                        DagMlError::RuntimeValidation(
1730                            "portable predictor calibration replay is missing its selected binding"
1731                                .to_string(),
1732                        )
1733                    })?;
1734                let [point] = replay_output.predictions.as_slice() else {
1735                    return contract_error(
1736                        "portable predictor calibration replay requires exactly one selected point block"
1737                            .to_string(),
1738                    );
1739                };
1740                if replay_output.binding != *binding
1741                    || point.sample_ids != calibration.sample_ids
1742                    || point.sample_ids != context.calibration_cohort.physical_sample_ids
1743                    || !replay.conformal_intervals.is_empty()
1744                    || replay.input_data_identities.iter().any(|identity| {
1745                        identity.relation_fingerprint != context.relation_fingerprint
1746                    })
1747                {
1748                    return contract_error(
1749                        "portable predictor calibration replay evidence does not match its selected binding, samples, or relation authority"
1750                            .to_string(),
1751                    );
1752                }
1753                let training_ids = self
1754                    .training_influence
1755                    .entries
1756                    .iter()
1757                    .flat_map(|entry| {
1758                        entry
1759                            .physical_sample_ids
1760                            .iter()
1761                            .chain(entry.origin_sample_ids.iter())
1762                    })
1763                    .collect::<BTreeSet<_>>();
1764                if context
1765                    .calibration_cohort
1766                    .physical_sample_ids
1767                    .iter()
1768                    .chain(context.calibration_cohort.origin_sample_ids.iter())
1769                    .any(|sample_id| training_ids.contains(sample_id))
1770                {
1771                    return contract_error(
1772                        "portable predictor calibration cohort overlaps training influence closure"
1773                            .to_string(),
1774                    );
1775                }
1776            }
1777            (None, None, None) => {
1778                if self
1779                    .training_outcome
1780                    .pre_conformal_outcome_fingerprint
1781                    .is_some()
1782                {
1783                    return contract_error(
1784                        "portable predictor pre-conformal source requires conformal state"
1785                            .to_string(),
1786                    );
1787                }
1788            }
1789            _ => {
1790                return contract_error(
1791                    "portable predictor package and execution bundle conformal state disagree"
1792                        .to_string(),
1793                )
1794            }
1795        }
1796        let effective_plan_fingerprint =
1797            tcv1_fingerprint(&self.effective_plan, "portable predictor effective plan")?;
1798        if effective_plan_fingerprint != self.training_outcome.effective_plan_fingerprint {
1799            return contract_error(
1800                "portable predictor effective plan fingerprint is not cross-linked by outcome reference"
1801                    .to_string(),
1802            );
1803        }
1804        if self.execution_bundle.bundle_id != self.training_outcome.execution_bundle_id {
1805            return contract_error(
1806                "portable predictor bundle id is not cross-linked by outcome reference".to_string(),
1807            );
1808        }
1809        let execution_bundle_fingerprint = tcv1_fingerprint(
1810            &self.execution_bundle,
1811            "portable predictor execution bundle",
1812        )?;
1813        if execution_bundle_fingerprint != self.training_outcome.execution_bundle_fingerprint {
1814            return contract_error(
1815                "portable predictor execution bundle content is not cross-linked by outcome reference"
1816                    .to_string(),
1817            );
1818        }
1819        self.training_influence.validate()?;
1820        if self.training_influence.manifest_fingerprint
1821            != self.training_outcome.training_influence_fingerprint
1822        {
1823            return contract_error(
1824                "portable predictor influence is not cross-linked by outcome reference".to_string(),
1825            );
1826        }
1827        if self.output_bindings.is_empty() {
1828            return contract_error(
1829                "portable predictor package requires at least one output binding".to_string(),
1830            );
1831        }
1832        let mut previous_binding: Option<&str> = None;
1833        let mut output_nodes = Vec::new();
1834        let mut coordinates = BTreeSet::new();
1835        for binding in &self.output_bindings {
1836            if previous_binding.is_some_and(|previous| previous >= binding.binding_id.as_str()) {
1837                return contract_error(
1838                    "portable predictor output bindings must be sorted by binding_id".to_string(),
1839                );
1840            }
1841            previous_binding = Some(binding.binding_id.as_str());
1842            binding.validate(&self.effective_plan.graph_plan.graph)?;
1843            if !coordinates.insert((binding.node_id.clone(), binding.port_name.clone())) {
1844                return contract_error(format!(
1845                    "portable predictor binds `{}.{}` more than once",
1846                    binding.node_id, binding.port_name
1847                ));
1848            }
1849            if binding.prediction_source == PredictionSource::FinalRefit
1850                && self.execution_bundle.refit_artifacts.is_empty()
1851            {
1852                return contract_error(
1853                    "final_refit output binding requires refit artifacts".to_string(),
1854                );
1855            }
1856            output_nodes.push(&binding.node_id);
1857        }
1858        if self
1859            .output_bindings
1860            .iter()
1861            .map(|binding| binding.binding_fingerprint.clone())
1862            .collect::<Vec<_>>()
1863            != self.training_outcome.output_binding_fingerprints
1864        {
1865            return contract_error(
1866                "portable predictor output bindings are not cross-linked by outcome reference"
1867                    .to_string(),
1868            );
1869        }
1870        let expected_closure = predictor_closure(&self.effective_plan, output_nodes)?;
1871        validate_sorted_unique_ids(
1872            "portable predictor_node_ids",
1873            &self.predictor_node_ids,
1874            true,
1875        )?;
1876        if self
1877            .predictor_node_ids
1878            .iter()
1879            .cloned()
1880            .collect::<BTreeSet<_>>()
1881            != expected_closure
1882        {
1883            return contract_error(
1884                "portable predictor_node_ids do not exactly match output closure".to_string(),
1885            );
1886        }
1887        if self.training_influence.entries.iter().any(|entry| {
1888            entry
1889                .node_id
1890                .as_ref()
1891                .is_some_and(|node_id| !expected_closure.contains(node_id))
1892        }) {
1893            return contract_error(
1894                "portable predictor influence references a node outside predictor closure"
1895                    .to_string(),
1896            );
1897        }
1898        validate_package_base_influence(
1899            &self.training_influence,
1900            &self.effective_plan,
1901            &expected_closure,
1902        )?;
1903        validate_package_data_identities(self)?;
1904        let data_identities_fingerprint =
1905            tcv1_fingerprint(&self.data_identities, "portable predictor data identities")?;
1906        if data_identities_fingerprint != self.training_outcome.data_identities_fingerprint {
1907            return contract_error(
1908                "portable predictor data identity content is not cross-linked by outcome reference"
1909                    .to_string(),
1910            );
1911        }
1912        if self.data_identities.iter().any(|identity| {
1913            identity.relation_fingerprint != self.training_influence.relation_fingerprint
1914        }) {
1915            return contract_error(
1916                "portable predictor data identities and training influence bind different relations"
1917                    .to_string(),
1918            );
1919        }
1920        validate_package_artifact_bindings(self)?;
1921        // A portable package is a deployable predictor, so it must independently
1922        // prove PREDICT replayability from its own plan/closure/retained artifacts
1923        // — never infer portability from a merely non-empty claimed phase set. An
1924        // outcome that only reaches REFIT (skipped refit) or has no honest replay
1925        // mode ([]) carries no full-training predictor and is refused here.
1926        if !crate::training_runtime::closure_predict_replayable(
1927            &self.effective_plan,
1928            &expected_closure,
1929            &self.execution_bundle,
1930        )? {
1931            return contract_error(
1932                "portable predictor package is not PREDICT-replayable: its predictor closure does not support PREDICT with self-contained retained artifacts".to_string(),
1933            );
1934        }
1935        let value = serde_json::to_value(self)?;
1936        if contains_runtime_handle(&value) {
1937            return contract_error(
1938                "portable predictor package must not contain runtime handles".to_string(),
1939            );
1940        }
1941        if self.package_fingerprint != self.compute_fingerprint()? {
1942            return contract_error(
1943                "portable predictor package fingerprint does not match TCV1 content".to_string(),
1944            );
1945        }
1946        Ok(())
1947    }
1948
1949    pub fn load_with<H>(
1950        self,
1951        mut resolver: impl FnMut(&RefitArtifactRecord) -> Result<H>,
1952    ) -> Result<LoadedPredictor<H>> {
1953        self.validate()?;
1954        let mut artifacts = BTreeMap::new();
1955        let sidecar_ids = self
1956            .artifact_bindings
1957            .iter()
1958            .filter(|binding| binding.load_mode == ArtifactLoadMode::HostSidecar)
1959            .map(|binding| &binding.artifact_id)
1960            .collect::<BTreeSet<_>>();
1961        for record in self
1962            .execution_bundle
1963            .refit_artifacts
1964            .iter()
1965            .filter(|record| sidecar_ids.contains(&record.artifact.id))
1966        {
1967            let handle = resolver(record)?;
1968            artifacts.insert(record.artifact.id.clone(), handle);
1969        }
1970        LoadedPredictor::new(self, artifacts)
1971    }
1972}
1973
1974/// Process-local sidecar. It deliberately implements neither `Serialize` nor
1975/// `Deserialize`, so opaque host objects cannot leak into portable packages.
1976pub struct LoadedPredictor<H> {
1977    package: PortablePredictorPackage,
1978    artifacts: BTreeMap<ArtifactId, H>,
1979}
1980
1981impl<H> LoadedPredictor<H> {
1982    pub fn new(
1983        package: PortablePredictorPackage,
1984        artifacts: BTreeMap<ArtifactId, H>,
1985    ) -> Result<Self> {
1986        package.validate()?;
1987        let expected = package
1988            .artifact_bindings
1989            .iter()
1990            .filter(|binding| binding.load_mode == ArtifactLoadMode::HostSidecar)
1991            .map(|binding| binding.artifact_id.clone())
1992            .collect::<BTreeSet<_>>();
1993        let actual = artifacts.keys().cloned().collect::<BTreeSet<_>>();
1994        if actual != expected {
1995            return contract_error(
1996                "loaded predictor sidecar artifacts do not exactly match package references"
1997                    .to_string(),
1998            );
1999        }
2000        Ok(Self { package, artifacts })
2001    }
2002
2003    pub fn package(&self) -> &PortablePredictorPackage {
2004        &self.package
2005    }
2006
2007    pub fn artifact(&self, artifact_id: &ArtifactId) -> Option<&H> {
2008        self.artifacts.get(artifact_id)
2009    }
2010
2011    pub fn into_parts(self) -> (PortablePredictorPackage, BTreeMap<ArtifactId, H>) {
2012        (self.package, self.artifacts)
2013    }
2014}
2015
2016fn validate_output_unit_level(
2017    prediction_level: PredictionLevel,
2018    unit_level: Option<EntityUnitLevel>,
2019) -> Result<()> {
2020    match prediction_level {
2021        PredictionLevel::Sample if unit_level != Some(EntityUnitLevel::PhysicalSample) => {
2022            contract_error("sample-level output requires unit_level=physical_sample".to_string())
2023        }
2024        PredictionLevel::Target | PredictionLevel::Group if unit_level.is_some() => {
2025            contract_error("target/group output requires unit_level=null".to_string())
2026        }
2027        _ => Ok(()),
2028    }
2029}
2030
2031fn validate_output_shape(
2032    prediction_kind: PredictionKind,
2033    output_order: OutputOrder,
2034    target_names: &[String],
2035    target_units: &[Option<String>],
2036    class_labels: &[Vec<String>],
2037    target_space: &str,
2038) -> Result<()> {
2039    validate_non_empty("output target_space", target_space)?;
2040    validate_unique_text("output target_names", target_names, true)?;
2041    if target_units.len() != target_names.len() || class_labels.len() != target_names.len() {
2042        return contract_error(
2043            "output target_units and class_labels must have one entry per target".to_string(),
2044        );
2045    }
2046    for unit in target_units.iter().flatten() {
2047        validate_non_empty("output target unit", unit)?;
2048    }
2049    let class_output = prediction_kind == PredictionKind::ClassProbability;
2050    for labels in class_labels {
2051        validate_unique_text("output class labels", labels, class_output)?;
2052    }
2053    if prediction_kind == PredictionKind::RegressionPoint
2054        && class_labels.iter().any(|labels| !labels.is_empty())
2055    {
2056        return contract_error("regression output class label arrays must be empty".to_string());
2057    }
2058    match (prediction_kind, output_order) {
2059        (PredictionKind::ClassProbability, OutputOrder::TargetMajorClassMinor) => Ok(()),
2060        (PredictionKind::ClassProbability, _) => contract_error(
2061            "class_probability output requires target_major_class_minor order".to_string(),
2062        ),
2063        (_, OutputOrder::TargetOrder) => Ok(()),
2064        _ => contract_error("non-probability output requires target_order".to_string()),
2065    }
2066}
2067
2068fn validate_canonical_patches(patches: &[ParameterPatch]) -> Result<()> {
2069    let mut previous: Option<(&NodeId, ParameterNamespace, &[String])> = None;
2070    for patch in patches {
2071        patch.validate()?;
2072        let key = (&patch.node_id, patch.namespace, patch.path.as_slice());
2073        if let Some(previous_key) = previous.as_ref() {
2074            if previous_key >= &key {
2075                return contract_error(
2076                    "parameter patches must be strictly sorted by (node_id, namespace, path)"
2077                        .to_string(),
2078                );
2079            }
2080            if previous_key.0 == key.0
2081                && previous_key.1 == key.1
2082                && (key.2.starts_with(previous_key.2) || previous_key.2.starts_with(key.2))
2083            {
2084                return contract_error(format!(
2085                    "parameter patches for `{}` contain a conflicting parent/child path",
2086                    patch.node_id
2087                ));
2088            }
2089        }
2090        previous = Some(key);
2091    }
2092    Ok(())
2093}
2094
2095fn validate_patch_policies(
2096    plan: &ExecutionPlan,
2097    policies: &[NodePatchPolicy],
2098) -> Result<BTreeMap<NodeId, BTreeSet<ParameterNamespace>>> {
2099    let mut previous: Option<&NodeId> = None;
2100    let mut result = BTreeMap::new();
2101    for policy in policies {
2102        policy.validate()?;
2103        if previous.is_some_and(|previous| previous >= &policy.node_id) {
2104            return contract_error(
2105                "node patch policies must be strictly sorted by node_id".to_string(),
2106            );
2107        }
2108        previous = Some(&policy.node_id);
2109        if !plan.node_plans.contains_key(&policy.node_id) {
2110            return contract_error(format!(
2111                "node patch policy references unknown node `{}`",
2112                policy.node_id
2113            ));
2114        }
2115        result.insert(policy.node_id.clone(), policy.allowed_namespaces.clone());
2116    }
2117    Ok(result)
2118}
2119
2120fn deep_set_object_key(
2121    root: &mut BTreeMap<String, serde_json::Value>,
2122    path: &[String],
2123    value: serde_json::Value,
2124    node_id: &NodeId,
2125) -> Result<()> {
2126    if path.len() == 1 {
2127        root.insert(path[0].clone(), value);
2128        return Ok(());
2129    }
2130    let first = root.get_mut(&path[0]).ok_or_else(|| {
2131        DagMlError::CampaignValidation(format!(
2132            "parameter patch for `{node_id}` is missing intermediate path `{}`",
2133            path[0]
2134        ))
2135    })?;
2136    let mut cursor = first;
2137    for segment in &path[1..path.len() - 1] {
2138        let object = cursor.as_object_mut().ok_or_else(|| {
2139            DagMlError::CampaignValidation(format!(
2140                "parameter patch for `{node_id}` crosses a scalar or array at `{segment}`"
2141            ))
2142        })?;
2143        cursor = object.get_mut(segment).ok_or_else(|| {
2144            DagMlError::CampaignValidation(format!(
2145                "parameter patch for `{node_id}` is missing intermediate path `{segment}`"
2146            ))
2147        })?;
2148    }
2149    let object = cursor.as_object_mut().ok_or_else(|| {
2150        DagMlError::CampaignValidation(format!(
2151            "parameter patch for `{node_id}` crosses a scalar or array before final key"
2152        ))
2153    })?;
2154    object.insert(path[path.len() - 1].clone(), value);
2155    Ok(())
2156}
2157
2158fn predictor_closure<'a>(
2159    plan: &ExecutionPlan,
2160    roots: impl IntoIterator<Item = &'a NodeId>,
2161) -> Result<BTreeSet<NodeId>> {
2162    let mut pending = roots.into_iter().cloned().collect::<Vec<_>>();
2163    let mut closure = BTreeSet::new();
2164    while let Some(node_id) = pending.pop() {
2165        if !closure.insert(node_id.clone()) {
2166            continue;
2167        }
2168        let node = plan.node_plans.get(&node_id).ok_or_else(|| {
2169            DagMlError::CampaignValidation(format!(
2170                "predictor closure references unknown node `{node_id}`"
2171            ))
2172        })?;
2173        pending.extend(node.input_nodes.iter().cloned());
2174    }
2175    Ok(closure)
2176}
2177
2178fn base_influence_kind(plan: &ExecutionPlan, node_id: &NodeId) -> Option<TrainingInfluenceKind> {
2179    let node_plan = &plan.node_plans[node_id];
2180    if matches!(
2181        node_plan.fit_scope,
2182        ControllerFitScope::Stateless | ControllerFitScope::InferenceOnly
2183    ) {
2184        return None;
2185    }
2186    let oof_consumers = plan
2187        .graph_plan
2188        .graph
2189        .edges
2190        .iter()
2191        .filter(|edge| edge.contract.requires_oof)
2192        .map(|edge| edge.target.node_id.clone())
2193        .collect::<BTreeSet<_>>();
2194    Some(
2195        if oof_consumers.contains(node_id)
2196            || node_plan
2197                .controller_capabilities
2198                .contains(&ControllerCapability::TrainsAggregation)
2199        {
2200            TrainingInfluenceKind::TrainedMetaAggregation
2201        } else if node_plan.kind == NodeKind::Model {
2202            TrainingInfluenceKind::ModelFit
2203        } else if node_plan.kind == NodeKind::Tuner {
2204            TrainingInfluenceKind::HpoSelection
2205        } else {
2206            TrainingInfluenceKind::TransformFit
2207        },
2208    )
2209}
2210
2211fn capability_influence_kinds(
2212    plan: &ExecutionPlan,
2213    node_id: &NodeId,
2214) -> BTreeSet<TrainingInfluenceKind> {
2215    let capabilities = &plan.node_plans[node_id].controller_capabilities;
2216    let mut kinds = BTreeSet::new();
2217    if capabilities.contains(&ControllerCapability::PerformsInternalTuning)
2218        && base_influence_kind(plan, node_id) != Some(TrainingInfluenceKind::HpoSelection)
2219    {
2220        kinds.insert(TrainingInfluenceKind::HpoSelection);
2221    }
2222    if capabilities.contains(&ControllerCapability::UsesEarlyStopping) {
2223        kinds.insert(TrainingInfluenceKind::EarlyStopping);
2224    }
2225    if capabilities.contains(&ControllerCapability::UsesTrainingWeights) {
2226        kinds.insert(TrainingInfluenceKind::WeightingResampling);
2227    }
2228    kinds
2229}
2230
2231fn expected_influence_coordinates(
2232    request: &TrainingRequest,
2233    plan: &ExecutionPlan,
2234    closure: &BTreeSet<NodeId>,
2235) -> Result<ExpectedInfluenceCoordinates> {
2236    let fold_set = plan.fold_set.as_ref().ok_or_else(|| {
2237        DagMlError::CampaignValidation(
2238            "training influence scopes require an explicit fold_set".to_string(),
2239        )
2240    })?;
2241    let all_samples = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
2242    let mut expected = BTreeMap::new();
2243    for node_id in closure {
2244        let node_plan = &plan.node_plans[node_id];
2245        let Some(base_kind) = base_influence_kind(plan, node_id) else {
2246            continue;
2247        };
2248        let mut scopes = Vec::<(String, BTreeSet<SampleId>)>::new();
2249        if node_plan.supported_phases.contains(&Phase::FitCv) {
2250            match node_plan.fit_scope {
2251                ControllerFitScope::FoldTrain => {
2252                    scopes.extend(fold_set.folds.iter().map(|fold| {
2253                        (
2254                            format!("fit_cv:{}", fold.fold_id),
2255                            fold.train_sample_ids.iter().cloned().collect(),
2256                        )
2257                    }));
2258                }
2259                ControllerFitScope::FullTrain => {
2260                    // ControllerManifest::validate rejects FullTrain + FIT_CV.
2261                    // Keep exhaustive all-sample accounting as defense in depth
2262                    // if a hand-built plan ever bypasses that invariant.
2263                    scopes.push(("fit_cv:full".to_string(), all_samples.clone()));
2264                }
2265                ControllerFitScope::Stateless | ControllerFitScope::InferenceOnly => {}
2266            }
2267        }
2268        if request.options.refit && node_plan.supported_phases.contains(&Phase::Refit) {
2269            scopes.push(("refit:full".to_string(), all_samples.clone()));
2270        }
2271        for (scope_id, samples) in scopes {
2272            expected.insert((base_kind, scope_id, Some(node_id.clone())), samples);
2273        }
2274    }
2275    for requirement in &request.influence_requirements {
2276        let key = (
2277            requirement.kind,
2278            requirement.scope_id.clone(),
2279            Some(requirement.node_id.clone()),
2280        );
2281        if expected
2282            .insert(
2283                key,
2284                requirement.physical_sample_ids.iter().cloned().collect(),
2285            )
2286            .is_some()
2287        {
2288            return contract_error(format!(
2289                "controller influence scope `{}` collides with a derived influence coordinate",
2290                requirement.scope_id
2291            ));
2292        }
2293    }
2294    expected.insert(
2295        (
2296            TrainingInfluenceKind::HpoSelection,
2297            format!("select:{}", request.options.selection.id),
2298            None,
2299        ),
2300        all_samples,
2301    );
2302    Ok(expected)
2303}
2304
2305fn validate_influence_requirements(
2306    request: &TrainingRequest,
2307    plan: &ExecutionPlan,
2308    closure: &BTreeSet<NodeId>,
2309) -> Result<()> {
2310    let fold_set = plan.fold_set.as_ref().ok_or_else(|| {
2311        DagMlError::CampaignValidation(
2312            "controller influence requirements need an explicit fold_set".to_string(),
2313        )
2314    })?;
2315    let all_samples = fold_set.sample_ids.iter().cloned().collect::<BTreeSet<_>>();
2316    let mut expected_slots = BTreeMap::<InfluenceCapabilitySlot, BTreeSet<SampleId>>::new();
2317    for node_id in closure {
2318        let node_plan = &plan.node_plans[node_id];
2319        if base_influence_kind(plan, node_id).is_none() {
2320            continue;
2321        }
2322        let kinds = capability_influence_kinds(plan, node_id);
2323        if node_plan.supported_phases.contains(&Phase::FitCv) {
2324            match node_plan.fit_scope {
2325                ControllerFitScope::FoldTrain => {
2326                    for fold in &fold_set.folds {
2327                        for kind in &kinds {
2328                            expected_slots.insert(
2329                                (
2330                                    node_id.clone(),
2331                                    *kind,
2332                                    Phase::FitCv,
2333                                    Some(fold.fold_id.clone()),
2334                                ),
2335                                fold.train_sample_ids.iter().cloned().collect(),
2336                            );
2337                        }
2338                    }
2339                }
2340                ControllerFitScope::FullTrain => {
2341                    // Unreachable for a validated manifest (FullTrain cannot
2342                    // support FIT_CV), but never under-report influence if an
2343                    // invalid hand-built plan reaches this helper.
2344                    for kind in &kinds {
2345                        expected_slots.insert(
2346                            (node_id.clone(), *kind, Phase::FitCv, None),
2347                            all_samples.clone(),
2348                        );
2349                    }
2350                }
2351                ControllerFitScope::Stateless | ControllerFitScope::InferenceOnly => {}
2352            }
2353        }
2354        if request.options.refit && node_plan.supported_phases.contains(&Phase::Refit) {
2355            for kind in kinds {
2356                expected_slots.insert(
2357                    (node_id.clone(), kind, Phase::Refit, None),
2358                    all_samples.clone(),
2359                );
2360            }
2361        }
2362    }
2363    let mut actual_slots = BTreeSet::new();
2364    let mut previous: Option<(TrainingInfluenceKind, &str, &NodeId)> = None;
2365    for requirement in &request.influence_requirements {
2366        validate_identifier_text(
2367            "controller influence requirement scope_id",
2368            &requirement.scope_id,
2369        )?;
2370        validate_sorted_unique_ids(
2371            "controller influence requirement physical_sample_ids",
2372            &requirement.physical_sample_ids,
2373            true,
2374        )?;
2375        let key = (
2376            requirement.kind,
2377            requirement.scope_id.as_str(),
2378            &requirement.node_id,
2379        );
2380        if previous.as_ref().is_some_and(|previous| previous >= &key) {
2381            return contract_error(
2382                "controller influence requirements must be strictly sorted by (kind, scope_id, node_id)"
2383                    .to_string(),
2384            );
2385        }
2386        previous = Some(key);
2387        if !closure.contains(&requirement.node_id) {
2388            return contract_error(format!(
2389                "controller influence requirement node `{}` is outside predictor closure",
2390                requirement.node_id
2391            ));
2392        }
2393        if !matches!(requirement.phase, Phase::FitCv | Phase::Refit) {
2394            return contract_error(format!(
2395                "controller influence scope `{}` uses non-training phase {:?}",
2396                requirement.scope_id, requirement.phase
2397            ));
2398        }
2399        let slot = (
2400            requirement.node_id.clone(),
2401            requirement.kind,
2402            requirement.phase,
2403            requirement.fold_id.clone(),
2404        );
2405        let eligible_samples = expected_slots.get(&slot).ok_or_else(|| {
2406            DagMlError::CampaignValidation(format!(
2407                "controller influence scope `{}` is not required by active controller capabilities",
2408                requirement.scope_id
2409            ))
2410        })?;
2411        let actual_samples = requirement
2412            .physical_sample_ids
2413            .iter()
2414            .cloned()
2415            .collect::<BTreeSet<_>>();
2416        if !actual_samples.is_subset(eligible_samples) {
2417            let outer_validation_overlap = requirement
2418                .fold_id
2419                .as_ref()
2420                .and_then(|fold_id| fold_set.folds.iter().find(|fold| &fold.fold_id == fold_id))
2421                .is_some_and(|fold| {
2422                    fold.validation_sample_ids
2423                        .iter()
2424                        .any(|sample_id| actual_samples.contains(sample_id))
2425                });
2426            if outer_validation_overlap {
2427                return contract_error(format!(
2428                    "controller influence scope `{}` leaks outer validation samples",
2429                    requirement.scope_id
2430                ));
2431            }
2432            return contract_error(format!(
2433                "controller influence scope `{}` uses samples outside its training cohort",
2434                requirement.scope_id
2435            ));
2436        }
2437        match requirement.kind {
2438            TrainingInfluenceKind::WeightingResampling if actual_samples != *eligible_samples => {
2439                return contract_error(format!(
2440                    "weighting influence scope `{}` must cover its complete fit cohort",
2441                    requirement.scope_id
2442                ));
2443            }
2444            TrainingInfluenceKind::EarlyStopping
2445                if actual_samples.len() >= eligible_samples.len() =>
2446            {
2447                return contract_error(format!(
2448                    "early-stopping influence scope `{}` must be a strict training-cohort subset",
2449                    requirement.scope_id
2450                ));
2451            }
2452            _ => {}
2453        }
2454        if !actual_slots.insert(slot) {
2455            return contract_error(format!(
2456                "controller influence capability slot is declared more than once at `{}`",
2457                requirement.scope_id
2458            ));
2459        }
2460    }
2461    if actual_slots != expected_slots.into_keys().collect::<BTreeSet<_>>() {
2462        return contract_error(
2463            "controller influence requirements do not exactly cover active capability scopes"
2464                .to_string(),
2465        );
2466    }
2467    Ok(())
2468}
2469
2470fn validate_influence_identity_closure(
2471    entry: &TrainingInfluenceEntry,
2472    relations: &SampleRelationSet,
2473) -> Result<()> {
2474    let physical = entry.physical_sample_ids.iter().collect::<BTreeSet<_>>();
2475    let mut found = BTreeSet::new();
2476    let mut origins = BTreeSet::new();
2477    let mut groups = BTreeSet::new();
2478    for relation in &relations.records {
2479        if physical.contains(&relation.sample_id) {
2480            found.insert(&relation.sample_id);
2481            if let Some(origin) = &relation.origin_sample_id {
2482                origins.insert(origin.clone());
2483            }
2484            if let Some(group) = &relation.group_id {
2485                groups.insert(group.clone());
2486            }
2487        }
2488    }
2489    if found.len() != physical.len() {
2490        return contract_error(format!(
2491            "training influence `{}` contains physical samples absent from relation set",
2492            entry.scope_id
2493        ));
2494    }
2495    if entry
2496        .origin_sample_ids
2497        .iter()
2498        .cloned()
2499        .collect::<BTreeSet<_>>()
2500        != origins
2501    {
2502        return contract_error(format!(
2503            "training influence `{}` origin closure does not match relation set",
2504            entry.scope_id
2505        ));
2506    }
2507    if entry.group_ids.iter().cloned().collect::<BTreeSet<_>>() != groups {
2508        return contract_error(format!(
2509            "training influence `{}` group closure does not match relation set",
2510            entry.scope_id
2511        ));
2512    }
2513    Ok(())
2514}
2515
2516fn validate_output_controllers(
2517    plan: &ExecutionPlan,
2518    outputs: &[ResolvedTrainingOutput],
2519) -> Result<()> {
2520    for output in outputs {
2521        let node = &plan.node_plans[&output.node_id];
2522        if !node
2523            .controller_capabilities
2524            .contains(&ControllerCapability::EmitsPredictions)
2525        {
2526            return contract_error(format!(
2527                "training output node `{}` controller does not declare emits_predictions",
2528                output.node_id
2529            ));
2530        }
2531    }
2532    Ok(())
2533}
2534
2535fn validate_selection_output(
2536    plan: &ExecutionPlan,
2537    options: &TrainingOptions,
2538    outputs: &[ResolvedTrainingOutput],
2539) -> Result<()> {
2540    let output = outputs
2541        .iter()
2542        .find(|output| output.output_id == options.selection_output_id)
2543        .expect("TrainingOptions::validate resolved selection_output_id");
2544    let node_plan = &plan.node_plans[&output.node_id];
2545    if !node_plan.supported_phases.contains(&Phase::FitCv) {
2546        return contract_error(format!(
2547            "training selection output `{}` is not scorable in FIT_CV",
2548            output.output_id
2549        ));
2550    }
2551    let graph_node = plan
2552        .graph_plan
2553        .graph
2554        .nodes
2555        .iter()
2556        .find(|node| node.id == output.node_id)
2557        .expect("execution plan output node exists in graph");
2558    let binds_declared_prediction_port = graph_node
2559        .ports
2560        .outputs
2561        .iter()
2562        .any(|port| port.kind == PortKind::Prediction && port.name == output.port_name);
2563    if !binds_declared_prediction_port {
2564        return contract_error(format!(
2565            "training selection output `{}` port `{}.{}` is not a declared prediction port",
2566            output.output_id, output.node_id, output.port_name
2567        ));
2568    }
2569    let campaign_metric_level = plan.campaign.aggregation_policy.selection_metric_level;
2570    if output.prediction_level != campaign_metric_level {
2571        return contract_error(format!(
2572            "training selection output `{}` prediction level does not match campaign selection_metric_level",
2573            output.output_id
2574        ));
2575    }
2576    if options
2577        .selection
2578        .required_metric_level
2579        .is_some_and(|level| level != campaign_metric_level)
2580    {
2581        return contract_error(format!(
2582            "training selection output `{}` prediction level does not match selection.required_metric_level",
2583            output.output_id
2584        ));
2585    }
2586    let metric_name = options.selection.metric.name.as_str();
2587    let objective = options.selection.metric.objective;
2588    crate::metrics::RegressionMetricKind::resolve_for_prediction_kind(
2589        metric_name,
2590        objective,
2591        output.prediction_kind,
2592    )?;
2593    Ok(())
2594}
2595
2596fn validate_scheduler_capabilities(
2597    scheduler: &TrainingSchedulerOptions,
2598    plan: &ExecutionPlan,
2599    closure: &BTreeSet<NodeId>,
2600) -> Result<()> {
2601    let Some(backend) = scheduler.backend else {
2602        return Ok(());
2603    };
2604    for node_id in closure {
2605        let capabilities = &plan.node_plans[node_id].controller_capabilities;
2606        match backend {
2607            TrainingSchedulerBackend::Threads => {
2608                if !capabilities.contains(&ControllerCapability::ThreadSafe) {
2609                    return contract_error(format!(
2610                        "parallel thread scheduler requires thread_safe controller for `{node_id}`"
2611                    ));
2612                }
2613                if capabilities.contains(&ControllerCapability::NeedsPythonGil) {
2614                    return contract_error(format!(
2615                        "parallel thread scheduler refuses needs_python_gil controller for `{node_id}`"
2616                    ));
2617                }
2618            }
2619            TrainingSchedulerBackend::Processes => {
2620                if !capabilities.contains(&ControllerCapability::ProcessSafe) {
2621                    return contract_error(format!(
2622                        "parallel process scheduler requires process_safe controller for `{node_id}`"
2623                    ));
2624                }
2625            }
2626        }
2627    }
2628    Ok(())
2629}
2630
2631fn validate_artifact_mode(
2632    artifacts: &TrainingArtifactOptions,
2633    plan: &ExecutionPlan,
2634    closure: &BTreeSet<NodeId>,
2635) -> Result<()> {
2636    if artifacts.fitted_artifacts != FittedArtifactMode::PortableRequired {
2637        return Ok(());
2638    }
2639    for node_id in closure {
2640        let node = &plan.node_plans[node_id];
2641        if node
2642            .controller_capabilities
2643            .contains(&ControllerCapability::EmitsArtifacts)
2644            && node.artifact_policy == ArtifactPolicy::HostOnly
2645        {
2646            return contract_error(format!(
2647                "portable_required training artifacts refuse host_only controller for `{node_id}`"
2648            ));
2649        }
2650    }
2651    Ok(())
2652}
2653
2654fn validate_training_data_identities(
2655    request: &TrainingRequest,
2656    plan: &ExecutionPlan,
2657) -> Result<()> {
2658    let mut expected = BTreeMap::new();
2659    for binding in plan.campaign.data_bindings.values().flatten() {
2660        let key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
2661        if let Some(previous) = expected.insert(key.clone(), binding) {
2662            let previous_coordinates = (&previous.node_id, previous.input_name.as_str());
2663            let coordinates = (&binding.node_id, binding.input_name.as_str());
2664            let detail = if previous_coordinates == coordinates {
2665                "duplicate coordinates"
2666            } else {
2667                "distinct coordinates collide under the V1 node.input spelling"
2668            };
2669            return contract_error(format!(
2670                "training data bindings render duplicate requirement key `{key}`: {detail}"
2671            ));
2672        }
2673    }
2674    let mut actual = BTreeMap::new();
2675    let mut previous: Option<&str> = None;
2676    for identity in &request.data_identities {
2677        identity.validate()?;
2678        if previous.is_some_and(|previous| previous >= identity.requirement_key.as_str()) {
2679            return contract_error(
2680                "training data identities must be strictly sorted by requirement_key".to_string(),
2681            );
2682        }
2683        previous = Some(identity.requirement_key.as_str());
2684        actual.insert(identity.requirement_key.as_str(), identity);
2685    }
2686    if actual.keys().copied().collect::<BTreeSet<_>>()
2687        != expected.keys().map(String::as_str).collect::<BTreeSet<_>>()
2688    {
2689        return contract_error(
2690            "training data identities must exactly cover campaign data bindings".to_string(),
2691        );
2692    }
2693    for (key, binding) in expected {
2694        let identity = actual[&key.as_str()];
2695        if identity.schema_fingerprint != binding.schema_fingerprint
2696            || identity.plan_fingerprint != binding.plan_fingerprint
2697            || binding.relation_fingerprint.as_deref()
2698                != Some(identity.relation_fingerprint.as_str())
2699        {
2700            return contract_error(format!(
2701                "training data identity `{key}` does not match data binding fingerprints"
2702            ));
2703        }
2704    }
2705    Ok(())
2706}
2707
2708fn validate_package_data_identities(package: &PortablePredictorPackage) -> Result<()> {
2709    let expected = package
2710        .execution_bundle
2711        .data_requirements
2712        .iter()
2713        .map(|requirement| (requirement.key(), requirement))
2714        .collect::<BTreeMap<_, _>>();
2715    let mut actual = BTreeMap::new();
2716    let mut previous: Option<&str> = None;
2717    for identity in &package.data_identities {
2718        identity.validate()?;
2719        if previous.is_some_and(|previous| previous >= identity.requirement_key.as_str()) {
2720            return contract_error(
2721                "portable predictor data identities must be sorted by requirement_key".to_string(),
2722            );
2723        }
2724        previous = Some(identity.requirement_key.as_str());
2725        actual.insert(identity.requirement_key.clone(), identity);
2726    }
2727    if actual.keys().collect::<BTreeSet<_>>() != expected.keys().collect::<BTreeSet<_>>() {
2728        return contract_error(
2729            "portable predictor data identities do not exactly match bundle requirements"
2730                .to_string(),
2731        );
2732    }
2733    for (key, requirement) in expected {
2734        let identity = actual[&key];
2735        if identity.schema_fingerprint != requirement.schema_fingerprint
2736            || identity.plan_fingerprint != requirement.plan_fingerprint
2737            || requirement.relation_fingerprint.as_deref()
2738                != Some(identity.relation_fingerprint.as_str())
2739        {
2740            return contract_error(format!(
2741                "portable predictor data identity `{key}` does not match bundle fingerprints"
2742            ));
2743        }
2744    }
2745    Ok(())
2746}
2747
2748fn influence_identity_closure_for_samples(
2749    scope_id: &str,
2750    samples: &BTreeSet<SampleId>,
2751    relations: &SampleRelationSet,
2752) -> Result<(Vec<SampleId>, Vec<GroupId>)> {
2753    let mut found = BTreeSet::new();
2754    let mut origins = BTreeSet::new();
2755    let mut groups = BTreeSet::new();
2756    for relation in &relations.records {
2757        if samples.contains(&relation.sample_id) {
2758            found.insert(&relation.sample_id);
2759            if let Some(origin) = &relation.origin_sample_id {
2760                origins.insert(origin.clone());
2761            }
2762            if let Some(group) = &relation.group_id {
2763                groups.insert(group.clone());
2764            }
2765        }
2766    }
2767    if found.len() != samples.len() {
2768        return contract_error(format!(
2769            "training influence `{scope_id}` contains physical samples absent from relation set"
2770        ));
2771    }
2772    Ok((origins.into_iter().collect(), groups.into_iter().collect()))
2773}
2774
2775fn validate_package_base_influence(
2776    influence: &TrainingInfluenceManifest,
2777    plan: &ExecutionPlan,
2778    closure: &BTreeSet<NodeId>,
2779) -> Result<()> {
2780    let expected = closure
2781        .iter()
2782        .filter(|node_id| {
2783            plan.node_plans[*node_id]
2784                .supported_phases
2785                .contains(&Phase::FitCv)
2786        })
2787        .filter_map(|node_id| base_influence_kind(plan, node_id).map(|kind| (node_id, kind)))
2788        .collect::<BTreeMap<_, _>>();
2789    let base_kinds = [
2790        TrainingInfluenceKind::TransformFit,
2791        TrainingInfluenceKind::ModelFit,
2792        TrainingInfluenceKind::HpoSelection,
2793        TrainingInfluenceKind::TrainedMetaAggregation,
2794    ]
2795    .into_iter()
2796    .collect::<BTreeSet<_>>();
2797    let mut actual = BTreeMap::<&NodeId, Vec<TrainingInfluenceKind>>::new();
2798    for entry in &influence.entries {
2799        if let Some(node_id) = entry.node_id.as_ref() {
2800            if base_kinds.contains(&entry.kind) {
2801                actual.entry(node_id).or_default().push(entry.kind);
2802            }
2803        }
2804    }
2805    if actual.keys().copied().collect::<BTreeSet<_>>()
2806        != expected.keys().copied().collect::<BTreeSet<_>>()
2807    {
2808        return contract_error(
2809            "portable predictor base-influence nodes do not exactly match predictor closure"
2810                .to_string(),
2811        );
2812    }
2813    for (node_id, expected_kind) in expected {
2814        let kinds = &actual[node_id];
2815        if kinds.is_empty() || kinds.iter().any(|kind| *kind != expected_kind) {
2816            return contract_error(format!(
2817                "portable predictor influence node `{node_id}` entries do not all have expected kind `{:?}`",
2818                expected_kind
2819            ));
2820        }
2821    }
2822    Ok(())
2823}
2824
2825fn validate_package_artifact_bindings(package: &PortablePredictorPackage) -> Result<()> {
2826    let mut previous: Option<&ArtifactId> = None;
2827    for binding in &package.artifact_bindings {
2828        if previous.is_some_and(|previous| previous >= &binding.artifact_id) {
2829            return contract_error(
2830                "portable predictor artifact bindings must be strictly sorted by artifact_id"
2831                    .to_string(),
2832            );
2833        }
2834        previous = Some(&binding.artifact_id);
2835    }
2836    let expected = package
2837        .execution_bundle
2838        .refit_artifacts
2839        .iter()
2840        .map(|record| record.artifact.id.clone())
2841        .collect::<BTreeSet<_>>();
2842    let actual = package
2843        .artifact_bindings
2844        .iter()
2845        .map(|binding| binding.artifact_id.clone())
2846        .collect::<BTreeSet<_>>();
2847    if actual != expected {
2848        return contract_error(
2849            "portable predictor artifact bindings do not exactly match bundle artifacts"
2850                .to_string(),
2851        );
2852    }
2853    for binding in &package.artifact_bindings {
2854        let record = package
2855            .execution_bundle
2856            .refit_artifacts
2857            .iter()
2858            .find(|record| record.artifact.id == binding.artifact_id)
2859            .expect("artifact id sets were checked above");
2860        let node_plan = &package.effective_plan.node_plans[&record.node_id];
2861        match binding.load_mode {
2862            ArtifactLoadMode::NativePortable => {
2863                record.artifact.validate_portable()?;
2864                if node_plan.artifact_policy == ArtifactPolicy::HostOnly {
2865                    return contract_error(format!(
2866                        "host_only artifact `{}` cannot be classified native_portable",
2867                        binding.artifact_id
2868                    ));
2869                }
2870            }
2871            ArtifactLoadMode::HostSidecar => {
2872                if package.fitted_artifact_mode != FittedArtifactMode::AllowHostSidecar {
2873                    return contract_error(format!(
2874                        "portable_required package forbids host sidecar artifact `{}`",
2875                        binding.artifact_id
2876                    ));
2877                }
2878            }
2879        }
2880    }
2881    Ok(())
2882}
2883
2884pub(crate) fn contains_runtime_handle(value: &serde_json::Value) -> bool {
2885    match value {
2886        serde_json::Value::Array(values) => values.iter().any(contains_runtime_handle),
2887        serde_json::Value::Object(values) => {
2888            values.keys().any(|key| {
2889                let key = key.to_ascii_lowercase();
2890                key == "handle" || key.ends_with("_handle") || key.ends_with("_handles")
2891            }) || values.values().any(contains_runtime_handle)
2892        }
2893        _ => false,
2894    }
2895}
2896
2897fn tcv1_fingerprint<T: Serialize + ?Sized>(value: &T, label: &str) -> Result<String> {
2898    let json = serde_json::to_string(value)?;
2899    parse_typed_json(&json)
2900        .and_then(|value| value.fingerprint())
2901        .map_err(|error| DagMlError::RuntimeValidation(format!("{label} is outside TCV1: {error}")))
2902}
2903
2904fn tcv1_fingerprint_without<T: Serialize>(value: &T, field: &str, label: &str) -> Result<String> {
2905    let json = serde_json::to_string(value)?;
2906    parse_typed_json(&json)
2907        .and_then(|value| value.fingerprint_without(field))
2908        .map_err(|error| DagMlError::RuntimeValidation(format!("{label} is outside TCV1: {error}")))
2909}
2910
2911fn strict_tcv1_fingerprint_without(json: &str, field: &str, label: &str) -> Result<String> {
2912    parse_typed_json(json)
2913        .and_then(|value| value.fingerprint_without(field))
2914        .map_err(|error| {
2915            DagMlError::RuntimeValidation(format!("{label} is outside strict TCV1: {error}"))
2916        })
2917}
2918
2919fn validate_sha256(label: &str, value: &str) -> Result<()> {
2920    if value.len() != 64
2921        || !value
2922            .bytes()
2923            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2924    {
2925        return contract_error(format!(
2926            "{label} fingerprint must be 64 lowercase hexadecimal characters"
2927        ));
2928    }
2929    Ok(())
2930}
2931
2932fn zero_fingerprint() -> String {
2933    "0".repeat(64)
2934}
2935
2936fn validate_identifier_text(label: &str, value: &str) -> Result<()> {
2937    if value.is_empty()
2938        || value.len() > 128
2939        || !value
2940            .bytes()
2941            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
2942    {
2943        return contract_error(format!("{label} is not a valid DAG-ML identifier"));
2944    }
2945    Ok(())
2946}
2947
2948fn validate_non_empty(label: &str, value: &str) -> Result<()> {
2949    if value.trim().is_empty() {
2950        return contract_error(format!("{label} must be non-empty"));
2951    }
2952    Ok(())
2953}
2954
2955fn validate_sorted_unique_text(
2956    label: &str,
2957    values: &[String],
2958    require_non_empty: bool,
2959) -> Result<()> {
2960    if require_non_empty && values.is_empty() {
2961        return contract_error(format!("{label} must be non-empty"));
2962    }
2963    let mut previous: Option<&str> = None;
2964    for value in values {
2965        validate_non_empty(label, value)?;
2966        if previous.is_some_and(|previous| previous >= value.as_str()) {
2967            return contract_error(format!("{label} must be strictly sorted and unique"));
2968        }
2969        previous = Some(value.as_str());
2970    }
2971    Ok(())
2972}
2973
2974fn validate_unique_text(label: &str, values: &[String], require_non_empty: bool) -> Result<()> {
2975    if require_non_empty && values.is_empty() {
2976        return contract_error(format!("{label} must be non-empty"));
2977    }
2978    let mut seen = BTreeSet::new();
2979    for value in values {
2980        validate_non_empty(label, value)?;
2981        if !seen.insert(value.as_str()) {
2982            return contract_error(format!("{label} must be unique"));
2983        }
2984    }
2985    Ok(())
2986}
2987
2988fn validate_sorted_unique_ids<T: Ord + std::fmt::Display>(
2989    label: &str,
2990    values: &[T],
2991    require_non_empty: bool,
2992) -> Result<()> {
2993    if require_non_empty && values.is_empty() {
2994        return contract_error(format!("{label} must be non-empty"));
2995    }
2996    if values.windows(2).any(|pair| pair[0] >= pair[1]) {
2997        return contract_error(format!("{label} must be strictly sorted and unique"));
2998    }
2999    Ok(())
3000}
3001
3002fn unsupported_version<T>(label: &str, actual: u32, expected: u32) -> Result<T> {
3003    contract_error(format!(
3004        "{label} uses unsupported schema_version {actual}, expected {expected}"
3005    ))
3006}
3007
3008fn contract_error<T>(message: String) -> Result<T> {
3009    Err(DagMlError::CampaignValidation(message))
3010}
3011
3012#[cfg(test)]
3013mod tests {
3014    use serde_json::{json, Value};
3015
3016    use super::*;
3017    #[cfg(dag_ml_workspace_contract_fixtures)]
3018    use crate::ids::ControllerId;
3019    use crate::ids::ObservationId;
3020    use crate::relation::SampleRelation;
3021    use crate::selection::{MetricObjective, SelectionMetric};
3022
3023    fn manifests() -> Vec<ControllerManifest> {
3024        let all: Vec<ControllerManifest> = serde_json::from_str(include_str!(
3025            "../tests/fixtures/package/controller_manifests.json"
3026        ))
3027        .unwrap();
3028        let mut selected = all
3029            .into_iter()
3030            .filter(|manifest| {
3031                matches!(
3032                    manifest.operator_kind,
3033                    NodeKind::Transform | NodeKind::Model
3034                )
3035            })
3036            .collect::<Vec<_>>();
3037        selected.sort_by(|left, right| left.controller_id.cmp(&right.controller_id));
3038        selected
3039    }
3040
3041    fn data_identity(campaign: &CampaignSpec) -> TrainingDataIdentity {
3042        let binding = &campaign.data_bindings[&NodeId::new("model:base").unwrap()][0];
3043        let mut identity = TrainingDataIdentity {
3044            requirement_key: "model:base.x".to_string(),
3045            schema_fingerprint: binding.schema_fingerprint.clone(),
3046            plan_fingerprint: binding.plan_fingerprint.clone(),
3047            relation_fingerprint: binding.relation_fingerprint.clone().unwrap(),
3048            data_content_fingerprint: "1".repeat(64),
3049            target_content_fingerprint: "2".repeat(64),
3050            identity_fingerprint: zero_fingerprint(),
3051        };
3052        identity.identity_fingerprint = identity.compute_fingerprint().unwrap();
3053        identity
3054    }
3055
3056    fn request() -> TrainingRequest {
3057        let graph: GraphSpec =
3058            serde_json::from_str(include_str!("../tests/fixtures/package/minimal_graph.json"))
3059                .unwrap();
3060        let campaign: CampaignSpec = serde_json::from_str(include_str!(
3061            "../tests/fixtures/package/campaign_oof_generation.json"
3062        ))
3063        .unwrap();
3064        let mut request = TrainingRequest {
3065            schema_version: TRAINING_REQUEST_SCHEMA_VERSION,
3066            request_id: "training:request.test".to_string(),
3067            plan_id: "plan:training.test".to_string(),
3068            graph,
3069            data_identities: vec![data_identity(&campaign)],
3070            campaign,
3071            controller_manifests: manifests(),
3072            parameter_patches: Vec::new(),
3073            patch_policies: Vec::new(),
3074            influence_requirements: Vec::new(),
3075            training_losses: Vec::new(),
3076            options: TrainingOptions {
3077                refit: true,
3078                refit_strategy: Some(RefitStrategy::RefitOne),
3079                seed: 12345,
3080                selection: SelectionPolicy {
3081                    id: "selection:rmse".to_string(),
3082                    metric: SelectionMetric {
3083                        name: "rmse".to_string(),
3084                        objective: MetricObjective::Minimize,
3085                    },
3086                    required_metric_level: None,
3087                    require_finite: true,
3088                    evaluation_scope: None,
3089                    refit_slot_plan: None,
3090                    stacking_fit_contract: None,
3091                    reduction_id: None,
3092                },
3093                selection_output_id: "output:prediction".to_string(),
3094                outputs: vec![TrainingOutputRequest {
3095                    output_id: "output:prediction".to_string(),
3096                    node_id: NodeId::new("model:base").unwrap(),
3097                    port_name: None,
3098                    prediction_level: PredictionLevel::Sample,
3099                    unit_level: Some(EntityUnitLevel::PhysicalSample),
3100                    prediction_kind: PredictionKind::RegressionPoint,
3101                    target_names: vec!["protein".to_string()],
3102                    target_units: vec![Some("percent".to_string())],
3103                    class_labels: vec![Vec::new()],
3104                    output_order: OutputOrder::TargetOrder,
3105                    target_space: "raw".to_string(),
3106                }],
3107                scheduler: TrainingSchedulerOptions {
3108                    kind: TrainingSchedulerKind::Sequential,
3109                    backend: None,
3110                    workers: 1,
3111                },
3112                resources: TrainingResourceLimits {
3113                    cpu_threads: 1,
3114                    memory_bytes: Some(1024),
3115                    gpu_devices: Vec::new(),
3116                    wall_time_ms: Some(10_000),
3117                },
3118                artifacts: TrainingArtifactOptions {
3119                    cv_artifacts: CvArtifactRetention::MetadataOnly,
3120                    prediction_caches: PredictionCacheRetention::Retain,
3121                    fitted_artifacts: FittedArtifactMode::AllowHostSidecar,
3122                },
3123            },
3124            request_fingerprint: zero_fingerprint(),
3125        };
3126        request.request_fingerprint = request.compute_fingerprint().unwrap();
3127        request
3128    }
3129
3130    fn resign_request(request: &mut TrainingRequest) {
3131        request.request_fingerprint = zero_fingerprint();
3132        request.request_fingerprint = request.compute_fingerprint().unwrap();
3133    }
3134
3135    #[test]
3136    fn training_request_projects_identically_for_refit_on_and_off() {
3137        let request = request();
3138        let refit = request.project().unwrap();
3139        assert_eq!(refit.outputs[0].port_name, "oof");
3140        assert!(!refit.parameters.requires_recompile);
3141        assert_eq!(
3142            refit.predictor_node_ids,
3143            BTreeSet::from([
3144                NodeId::new("model:base").unwrap(),
3145                NodeId::new("transform:snv").unwrap(),
3146            ])
3147        );
3148
3149        let mut no_refit = request;
3150        no_refit.options.refit = false;
3151        no_refit.options.refit_strategy = None;
3152        resign_request(&mut no_refit);
3153        let projection = no_refit.project().unwrap();
3154        assert_eq!(projection.outputs, refit.outputs);
3155        assert_eq!(projection.plan, refit.plan);
3156    }
3157
3158    #[test]
3159    fn training_request_rejects_duplicate_rendered_data_requirement_keys() {
3160        let mut request = request();
3161        let node_id = NodeId::new("model:base").unwrap();
3162        let duplicate = request.campaign.data_bindings[&node_id][0].clone();
3163        request
3164            .campaign
3165            .data_bindings
3166            .get_mut(&node_id)
3167            .unwrap()
3168            .push(duplicate);
3169        resign_request(&mut request);
3170
3171        let error = request.project().unwrap_err();
3172        assert!(error
3173            .to_string()
3174            .contains("render duplicate requirement key `model:base.x`"));
3175    }
3176
3177    #[test]
3178    fn training_options_reject_unknown_fields_and_binary64_integer_substitution() {
3179        let value = serde_json::to_value(request()).unwrap();
3180        let mut unknown = value.clone();
3181        unknown["options"]["mystery"] = json!(true);
3182        let error = serde_json::from_value::<TrainingRequest>(unknown).unwrap_err();
3183        assert!(error.to_string().contains("unknown field"));
3184
3185        let mut binary64_seed = value;
3186        binary64_seed["options"]["seed"] = json!(12345.0);
3187        assert!(serde_json::from_value::<TrainingRequest>(binary64_seed).is_err());
3188    }
3189
3190    #[test]
3191    fn training_request_from_json_requires_explicit_patch_collections() {
3192        let request_json = serde_json::to_value(request()).unwrap();
3193        for field in ["parameter_patches", "patch_policies"] {
3194            let mut missing = request_json.clone();
3195            missing.as_object_mut().unwrap().remove(field);
3196            let error = TrainingRequest::from_json(&serde_json::to_string(&missing).unwrap())
3197                .expect_err("required patch collection omission must fail closed");
3198            assert!(error.to_string().contains(field), "{field}: {error}");
3199        }
3200    }
3201
3202    /// Presence-strictness (W1-0): five schema-`required` fields — four of them
3203    /// nullable — must reject key omission at serde deserialization with a
3204    /// field-specific `missing field` error, while still accepting the valid
3205    /// explicit values `null` / `[]`. These deserialize the individual
3206    /// sub-structs directly, so no outer fingerprint can mask or trigger the
3207    /// failure: presence is the only thing under test.
3208    #[test]
3209    fn required_nullable_fields_reject_omission_but_accept_explicit_null() {
3210        let base = request();
3211
3212        // 1. TrainingOutputRequest.unit_level (required, nullable).
3213        let output = serde_json::to_value(&base.options.outputs[0]).unwrap();
3214        assert!(output.get("unit_level").is_some());
3215        let mut missing = output.clone();
3216        missing.as_object_mut().unwrap().remove("unit_level");
3217        let error = serde_json::from_value::<TrainingOutputRequest>(missing).unwrap_err();
3218        assert!(
3219            error.to_string().contains("missing field") && error.to_string().contains("unit_level"),
3220            "unit_level omission: {error}"
3221        );
3222        let mut null_unit = output;
3223        null_unit["unit_level"] = json!(null);
3224        assert!(serde_json::from_value::<TrainingOutputRequest>(null_unit)
3225            .unwrap()
3226            .unit_level
3227            .is_none());
3228
3229        // 2. TrainingResourceLimits.gpu_devices (required, non-nullable array).
3230        let resources = serde_json::to_value(&base.options.resources).unwrap();
3231        assert_eq!(resources["gpu_devices"], json!([]));
3232        let mut missing = resources.clone();
3233        missing.as_object_mut().unwrap().remove("gpu_devices");
3234        let error = serde_json::from_value::<TrainingResourceLimits>(missing).unwrap_err();
3235        assert!(
3236            error.to_string().contains("missing field")
3237                && error.to_string().contains("gpu_devices"),
3238            "gpu_devices omission: {error}"
3239        );
3240        assert!(serde_json::from_value::<TrainingResourceLimits>(resources)
3241            .unwrap()
3242            .gpu_devices
3243            .is_empty());
3244
3245        // 3. TrainingOptions.refit_strategy (required, nullable).
3246        let options = serde_json::to_value(&base.options).unwrap();
3247        assert!(options.get("refit_strategy").is_some());
3248        let mut missing = options.clone();
3249        missing.as_object_mut().unwrap().remove("refit_strategy");
3250        let error = serde_json::from_value::<TrainingOptions>(missing).unwrap_err();
3251        assert!(
3252            error.to_string().contains("missing field")
3253                && error.to_string().contains("refit_strategy"),
3254            "refit_strategy omission: {error}"
3255        );
3256        let mut null_strategy = options;
3257        null_strategy["refit_strategy"] = json!(null);
3258        assert!(serde_json::from_value::<TrainingOptions>(null_strategy)
3259            .unwrap()
3260            .refit_strategy
3261            .is_none());
3262
3263        // 4. ControllerInfluenceRequirement.fold_id (required, nullable).
3264        let requirement = serde_json::to_value(ControllerInfluenceRequirement {
3265            node_id: NodeId::new("model:base").unwrap(),
3266            kind: TrainingInfluenceKind::EarlyStopping,
3267            scope_id: "early:fold:0".to_string(),
3268            phase: Phase::FitCv,
3269            fold_id: Some(FoldId::new("fold:0").unwrap()),
3270            physical_sample_ids: vec![SampleId::new("sample:1").unwrap()],
3271        })
3272        .unwrap();
3273        assert!(requirement.get("fold_id").is_some());
3274        let mut missing = requirement.clone();
3275        missing.as_object_mut().unwrap().remove("fold_id");
3276        let error = serde_json::from_value::<ControllerInfluenceRequirement>(missing).unwrap_err();
3277        assert!(
3278            error.to_string().contains("missing field") && error.to_string().contains("fold_id"),
3279            "fold_id omission: {error}"
3280        );
3281        let mut null_fold = requirement;
3282        null_fold["fold_id"] = json!(null);
3283        assert!(
3284            serde_json::from_value::<ControllerInfluenceRequirement>(null_fold)
3285                .unwrap()
3286                .fold_id
3287                .is_none()
3288        );
3289
3290        // 5. TrainingInfluenceEntry.node_id (required, nullable).
3291        let entry = serde_json::to_value(TrainingInfluenceEntry {
3292            kind: TrainingInfluenceKind::ModelFit,
3293            scope_id: "model:base:fold:0".to_string(),
3294            node_id: Some(NodeId::new("model:base").unwrap()),
3295            physical_sample_ids: vec![SampleId::new("sample:1").unwrap()],
3296            origin_sample_ids: Vec::new(),
3297            group_ids: Vec::new(),
3298        })
3299        .unwrap();
3300        assert!(entry.get("node_id").is_some());
3301        let mut missing = entry.clone();
3302        missing.as_object_mut().unwrap().remove("node_id");
3303        let error = serde_json::from_value::<TrainingInfluenceEntry>(missing).unwrap_err();
3304        assert!(
3305            error.to_string().contains("missing field") && error.to_string().contains("node_id"),
3306            "node_id omission: {error}"
3307        );
3308        let mut null_node = entry;
3309        null_node["node_id"] = json!(null);
3310        assert!(serde_json::from_value::<TrainingInfluenceEntry>(null_node)
3311            .unwrap()
3312            .node_id
3313            .is_none());
3314    }
3315
3316    #[test]
3317    fn output_resolution_rejects_no_output_ambiguity_and_non_prediction_ports() {
3318        let request = request();
3319        let output = &request.options.outputs[0];
3320        let mut no_output = request.graph.clone();
3321        no_output.nodes[1].ports.outputs.clear();
3322        assert!(output
3323            .resolve(&no_output)
3324            .unwrap_err()
3325            .to_string()
3326            .contains("no prediction output"));
3327
3328        let mut ambiguous = request.graph.clone();
3329        let mut second = ambiguous.nodes[1].ports.outputs[0].clone();
3330        second.name = "probability".to_string();
3331        ambiguous.nodes[1].ports.outputs.push(second);
3332        assert!(output
3333            .resolve(&ambiguous)
3334            .unwrap_err()
3335            .to_string()
3336            .contains("multiple prediction outputs"));
3337
3338        let mut explicit = output.clone();
3339        explicit.port_name = Some("x".to_string());
3340        assert!(explicit.resolve(&request.graph).is_err());
3341    }
3342
3343    #[test]
3344    fn output_target_and_class_orders_are_semantic_not_lexically_sorted() {
3345        let graph = request().graph;
3346        let mut binding = OutputBinding {
3347            schema_version: OUTPUT_BINDING_SCHEMA_VERSION,
3348            binding_id: "output:ordered".to_string(),
3349            node_id: NodeId::new("model:base").unwrap(),
3350            port_name: "oof".to_string(),
3351            prediction_level: PredictionLevel::Sample,
3352            unit_level: Some(EntityUnitLevel::PhysicalSample),
3353            prediction_kind: PredictionKind::RegressionPoint,
3354            prediction_source: PredictionSource::FinalRefit,
3355            refit_strategy: Some(RefitStrategy::RefitOne),
3356            aggregation_fingerprint: "6".repeat(64),
3357            target_names: vec!["z_target".to_string(), "a_target".to_string()],
3358            target_units: vec![Some("z_unit".to_string()), Some("a_unit".to_string())],
3359            class_labels: vec![Vec::new(), Vec::new()],
3360            output_order: OutputOrder::TargetOrder,
3361            target_space: "raw".to_string(),
3362            binding_fingerprint: zero_fingerprint(),
3363        };
3364        binding.binding_fingerprint = binding.compute_fingerprint().unwrap();
3365        binding.validate(&graph).unwrap();
3366        let original = binding.binding_fingerprint.clone();
3367        binding.target_names.swap(0, 1);
3368        binding.binding_fingerprint = zero_fingerprint();
3369        binding.binding_fingerprint = binding.compute_fingerprint().unwrap();
3370        binding.validate(&graph).unwrap();
3371        assert_ne!(original, binding.binding_fingerprint);
3372    }
3373
3374    #[test]
3375    fn output_unit_levels_and_class_vocabularies_match_w0_contract() {
3376        let request = request();
3377        let graph = &request.graph;
3378        let mut output = request.options.outputs[0].clone();
3379
3380        output.unit_level = None;
3381        assert!(output
3382            .resolve(graph)
3383            .unwrap_err()
3384            .to_string()
3385            .contains("physical_sample"));
3386
3387        output.prediction_level = PredictionLevel::Target;
3388        output.unit_level = Some(EntityUnitLevel::PhysicalSample);
3389        assert!(output
3390            .resolve(graph)
3391            .unwrap_err()
3392            .to_string()
3393            .contains("unit_level=null"));
3394        output.unit_level = None;
3395        let target_wire = serde_json::to_value(&output).unwrap();
3396        assert_eq!(target_wire.get("unit_level"), Some(&Value::Null));
3397        output.resolve(graph).unwrap();
3398
3399        output.prediction_level = PredictionLevel::Sample;
3400        output.unit_level = Some(EntityUnitLevel::PhysicalSample);
3401        output.prediction_kind = PredictionKind::ClassLabel;
3402        output.class_labels = vec![Vec::new()];
3403        output.output_order = OutputOrder::TargetOrder;
3404        output.resolve(graph).unwrap();
3405        output.class_labels = vec![vec!["low".to_string(), "high".to_string()]];
3406        output.resolve(graph).unwrap();
3407
3408        output.prediction_kind = PredictionKind::DecisionScore;
3409        output.resolve(graph).unwrap();
3410        output.class_labels = vec![Vec::new()];
3411        output.resolve(graph).unwrap();
3412
3413        output.prediction_kind = PredictionKind::RegressionPoint;
3414        output.class_labels = vec![vec!["not-a-regression-class".to_string()]];
3415        assert!(output.resolve(graph).is_err());
3416
3417        output.prediction_kind = PredictionKind::ClassProbability;
3418        output.output_order = OutputOrder::TargetMajorClassMinor;
3419        output.class_labels = vec![Vec::new()];
3420        assert!(output.resolve(graph).is_err());
3421        output.class_labels = vec![vec!["low".to_string(), "high".to_string()]];
3422        output.resolve(graph).unwrap();
3423    }
3424
3425    #[test]
3426    fn selection_output_metric_matrix_is_explicit() {
3427        let mut level_mismatch = request();
3428        level_mismatch.options.outputs[0].prediction_level = PredictionLevel::Target;
3429        level_mismatch.options.outputs[0].unit_level = None;
3430        assert!(level_mismatch
3431            .options
3432            .selection
3433            .required_metric_level
3434            .is_none());
3435        resign_request(&mut level_mismatch);
3436        assert!(level_mismatch
3437            .validate()
3438            .unwrap_err()
3439            .to_string()
3440            .contains("campaign selection_metric_level"));
3441
3442        let mut regression = request();
3443        regression.options.selection.metric.objective = MetricObjective::Maximize;
3444        resign_request(&mut regression);
3445        assert!(regression
3446            .validate()
3447            .unwrap_err()
3448            .to_string()
3449            .contains("not supported for RegressionPoint"));
3450
3451        let mut class_label = request();
3452        class_label.options.outputs[0].prediction_kind = PredictionKind::ClassLabel;
3453        class_label.options.outputs[0].class_labels =
3454            vec![vec!["low".to_string(), "high".to_string()]];
3455        class_label.options.selection.metric.name = "accuracy".to_string();
3456        class_label.options.selection.metric.objective = MetricObjective::Maximize;
3457        resign_request(&mut class_label);
3458        class_label.validate().unwrap();
3459
3460        let mut probability = class_label.clone();
3461        probability.options.outputs[0].prediction_kind = PredictionKind::ClassProbability;
3462        probability.options.outputs[0].output_order = OutputOrder::TargetMajorClassMinor;
3463        resign_request(&mut probability);
3464        assert!(probability
3465            .validate()
3466            .unwrap_err()
3467            .to_string()
3468            .contains("not supported for ClassProbability"));
3469
3470        let mut decision = class_label;
3471        decision.options.outputs[0].prediction_kind = PredictionKind::DecisionScore;
3472        resign_request(&mut decision);
3473        assert!(decision
3474            .validate()
3475            .unwrap_err()
3476            .to_string()
3477            .contains("not supported for DecisionScore"));
3478    }
3479
3480    fn request_with_nested_params() -> TrainingRequest {
3481        let mut request = request();
3482        let model = request
3483            .graph
3484            .nodes
3485            .iter_mut()
3486            .find(|node| node.id.as_str() == "model:base")
3487            .unwrap();
3488        model.params.insert(
3489            "nested".to_string(),
3490            json!({"depth": {"alpha": 1}, "array": [1, 2]}),
3491        );
3492        resign_request(&mut request);
3493        request
3494    }
3495
3496    fn patch(namespace: ParameterNamespace, path: &[&str], value: Value) -> ParameterPatch {
3497        ParameterPatch {
3498            schema_version: PARAMETER_PATCH_SCHEMA_VERSION,
3499            node_id: NodeId::new("model:base").unwrap(),
3500            namespace,
3501            path: path.iter().map(|part| (*part).to_string()).collect(),
3502            value,
3503        }
3504    }
3505
3506    fn patch_policy(namespaces: &[ParameterNamespace]) -> NodePatchPolicy {
3507        NodePatchPolicy {
3508            node_id: NodeId::new("model:base").unwrap(),
3509            allowed_namespaces: namespaces.iter().copied().collect(),
3510        }
3511    }
3512
3513    #[test]
3514    fn namespaced_deep_patch_is_isolated_bijective_and_structural() {
3515        let plan = request_with_nested_params().project().unwrap().plan;
3516        let original = plan.node_plans[&NodeId::new("model:base").unwrap()]
3517            .params
3518            .clone();
3519        let patches = vec![
3520            patch(
3521                ParameterNamespace::Operator,
3522                &["nested", "depth", "alpha"],
3523                json!(2),
3524            ),
3525            patch(ParameterNamespace::Fit, &["epochs"], json!(12)),
3526            patch(ParameterNamespace::Structural, &["topology"], json!("wide")),
3527        ];
3528        let projection = project_parameter_patches(
3529            &plan,
3530            &patches,
3531            &[patch_policy(&[
3532                ParameterNamespace::Operator,
3533                ParameterNamespace::Fit,
3534                ParameterNamespace::Structural,
3535            ])],
3536        )
3537        .unwrap();
3538        let node = &projection.nodes[&NodeId::new("model:base").unwrap()];
3539        assert_eq!(node.params["nested"]["depth"]["alpha"], json!(2));
3540        assert_eq!(node.fit_params["epochs"], json!(12));
3541        assert_eq!(node.structural_params["topology"], json!("wide"));
3542        assert!(projection.requires_recompile);
3543        assert_eq!(
3544            plan.node_plans[&NodeId::new("model:base").unwrap()].params,
3545            original
3546        );
3547        assert_eq!(ParameterNamespace::Operator.plan_root(), "params");
3548        assert_eq!(ParameterNamespace::Fit.plan_root(), "fit_params");
3549        assert_eq!(ParameterNamespace::Control.plan_root(), "control_params");
3550        assert_eq!(
3551            ParameterNamespace::Structural.plan_root(),
3552            "structural_params"
3553        );
3554
3555        let mut dishonest = projection.clone();
3556        dishonest.requires_recompile = false;
3557        dishonest.projection_fingerprint = zero_fingerprint();
3558        dishonest.projection_fingerprint = dishonest.compute_fingerprint().unwrap();
3559        assert!(dishonest.validate().is_err());
3560    }
3561
3562    #[test]
3563    fn patch_projection_rejects_namespace_order_duplicates_parent_child_and_arrays() {
3564        let plan = request_with_nested_params().project().unwrap().plan;
3565        let operator = patch_policy(&[ParameterNamespace::Operator]);
3566
3567        let forbidden = patch(ParameterNamespace::Fit, &["epochs"], json!(3));
3568        assert!(
3569            project_parameter_patches(&plan, &[forbidden], std::slice::from_ref(&operator))
3570                .unwrap_err()
3571                .to_string()
3572                .contains("forbidden")
3573        );
3574
3575        let duplicate = patch(ParameterNamespace::Operator, &["x"], json!(1));
3576        assert!(project_parameter_patches(
3577            &plan,
3578            &[duplicate.clone(), duplicate],
3579            std::slice::from_ref(&operator)
3580        )
3581        .is_err());
3582
3583        let parent = patch(
3584            ParameterNamespace::Operator,
3585            &["nested", "depth"],
3586            json!({"alpha": 2}),
3587        );
3588        let child = patch(
3589            ParameterNamespace::Operator,
3590            &["nested", "depth", "alpha"],
3591            json!(3),
3592        );
3593        assert!(project_parameter_patches(
3594            &plan,
3595            &[parent, child],
3596            std::slice::from_ref(&operator),
3597        )
3598        .unwrap_err()
3599        .to_string()
3600        .contains("parent/child"));
3601
3602        let array = patch(
3603            ParameterNamespace::Operator,
3604            &["nested", "array", "0"],
3605            json!(9),
3606        );
3607        assert!(
3608            project_parameter_patches(&plan, &[array], std::slice::from_ref(&operator)).is_err()
3609        );
3610
3611        let out_of_order = vec![
3612            patch(ParameterNamespace::Operator, &["z"], json!(1)),
3613            patch(ParameterNamespace::Operator, &["a"], json!(1)),
3614        ];
3615        assert!(project_parameter_patches(&plan, &out_of_order, &[operator]).is_err());
3616
3617        assert!(project_parameter_patches(
3618            &plan,
3619            &[],
3620            &[patch_policy(&[ParameterNamespace::Operator])]
3621        )
3622        .is_err());
3623    }
3624
3625    #[test]
3626    fn patch_tcv1_distinguishes_integer_and_binary64() {
3627        let integer = vec![patch(ParameterNamespace::Operator, &["x"], json!(2))];
3628        let binary64 = vec![patch(ParameterNamespace::Operator, &["x"], json!(2.0))];
3629        assert_ne!(
3630            tcv1_fingerprint(&integer, "integer patch").unwrap(),
3631            tcv1_fingerprint(&binary64, "binary64 patch").unwrap()
3632        );
3633    }
3634
3635    fn cache_namespace() -> CacheNamespace {
3636        let mut namespace = CacheNamespace {
3637            schema_version: CACHE_NAMESPACE_SCHEMA_VERSION,
3638            prediction_requirement_key: bundle_prediction_requirement_key(
3639                &NodeId::new("model:base").unwrap(),
3640                "oof",
3641                &NodeId::new("model:meta").unwrap(),
3642                "stacked",
3643            ),
3644            data_requirement_key: "model:base.x".to_string(),
3645            producer_node_id: NodeId::new("model:base").unwrap(),
3646            source_port_name: "oof".to_string(),
3647            consumer_node_id: NodeId::new("model:meta").unwrap(),
3648            target_port_name: "stacked".to_string(),
3649            phase: Phase::FitCv,
3650            params_fingerprint: "a".repeat(64),
3651            data_identity_fingerprint: "b".repeat(64),
3652            fold_id: FoldId::new("fold:0").unwrap(),
3653            trial_id: "trial:0".to_string(),
3654            seed: 7,
3655            namespace_fingerprint: zero_fingerprint(),
3656        };
3657        namespace.namespace_fingerprint = namespace.compute_fingerprint().unwrap();
3658        namespace
3659    }
3660
3661    #[test]
3662    fn cache_namespace_is_candidate_dataset_fold_trial_and_seed_specific() {
3663        let identity = request().data_identities.remove(0);
3664        let mut base = cache_namespace();
3665        base.data_identity_fingerprint = identity.identity_fingerprint.clone();
3666        base.namespace_fingerprint = zero_fingerprint();
3667        base.namespace_fingerprint = base.compute_fingerprint().unwrap();
3668        base.validate_for_identity(&identity).unwrap();
3669        for mutation in 0..5 {
3670            let mut changed = base.clone();
3671            match mutation {
3672                0 => changed.params_fingerprint = "d".repeat(64),
3673                1 => changed.data_identity_fingerprint = "e".repeat(64),
3674                2 => changed.fold_id = FoldId::new("fold:1").unwrap(),
3675                3 => changed.trial_id = "trial:1".to_string(),
3676                _ => changed.seed += 1,
3677            }
3678            changed.namespace_fingerprint = zero_fingerprint();
3679            changed.namespace_fingerprint = changed.compute_fingerprint().unwrap();
3680            changed.validate().unwrap();
3681            assert_ne!(base.namespace_fingerprint, changed.namespace_fingerprint);
3682        }
3683
3684        let mut value = serde_json::to_value(&base).unwrap();
3685        value["seed"] = json!(7.0);
3686        assert!(serde_json::from_value::<CacheNamespace>(value).is_err());
3687
3688        let mut relation_changed = identity.clone();
3689        relation_changed.relation_fingerprint = "d".repeat(64);
3690        relation_changed.identity_fingerprint = zero_fingerprint();
3691        relation_changed.identity_fingerprint = relation_changed.compute_fingerprint().unwrap();
3692        assert!(base.validate_for_identity(&relation_changed).is_err());
3693        let mut other_dataset_namespace = base.clone();
3694        other_dataset_namespace.data_identity_fingerprint =
3695            relation_changed.identity_fingerprint.clone();
3696        other_dataset_namespace.namespace_fingerprint = zero_fingerprint();
3697        other_dataset_namespace.namespace_fingerprint =
3698            other_dataset_namespace.compute_fingerprint().unwrap();
3699        assert_ne!(
3700            base.namespace_fingerprint,
3701            other_dataset_namespace.namespace_fingerprint
3702        );
3703
3704        let mut other_output = base.clone();
3705        other_output.source_port_name = "probability".to_string();
3706        other_output.prediction_requirement_key = bundle_prediction_requirement_key(
3707            &other_output.producer_node_id,
3708            &other_output.source_port_name,
3709            &other_output.consumer_node_id,
3710            &other_output.target_port_name,
3711        );
3712        other_output.namespace_fingerprint = zero_fingerprint();
3713        other_output.namespace_fingerprint = other_output.compute_fingerprint().unwrap();
3714        other_output.validate().unwrap();
3715        assert_ne!(
3716            base.namespace_fingerprint,
3717            other_output.namespace_fingerprint
3718        );
3719
3720        let mut wrong_phase = base.clone();
3721        wrong_phase.phase = Phase::Refit;
3722        wrong_phase.namespace_fingerprint = zero_fingerprint();
3723        wrong_phase.namespace_fingerprint = wrong_phase.compute_fingerprint().unwrap();
3724        assert!(wrong_phase.validate().is_err());
3725    }
3726
3727    fn relations() -> SampleRelationSet {
3728        let records = (1..=4)
3729            .map(|index| {
3730                let mut relation = SampleRelation::new(
3731                    ObservationId::new(format!("observation:{index}")).unwrap(),
3732                    SampleId::new(format!("sample:{index}")).unwrap(),
3733                );
3734                relation.group_id =
3735                    Some(GroupId::new(if index <= 2 { "group:0" } else { "group:1" }).unwrap());
3736                relation
3737            })
3738            .collect();
3739        SampleRelationSet { records }
3740    }
3741
3742    fn request_for_relations(relations: &SampleRelationSet) -> TrainingRequest {
3743        let mut request = request();
3744        let fingerprint = relations.fingerprint().unwrap();
3745        request
3746            .campaign
3747            .data_bindings
3748            .get_mut(&NodeId::new("model:base").unwrap())
3749            .unwrap()[0]
3750            .relation_fingerprint = Some(fingerprint.clone());
3751        request.data_identities[0].relation_fingerprint = fingerprint;
3752        request.data_identities[0].identity_fingerprint = zero_fingerprint();
3753        request.data_identities[0].identity_fingerprint =
3754            request.data_identities[0].compute_fingerprint().unwrap();
3755        resign_request(&mut request);
3756        request
3757    }
3758
3759    fn influence_manifest(
3760        request: &TrainingRequest,
3761        projection: &TrainingContractProjection,
3762        relations: &SampleRelationSet,
3763    ) -> TrainingInfluenceManifest {
3764        let expected = expected_influence_coordinates(
3765            request,
3766            &projection.plan,
3767            &projection.predictor_node_ids,
3768        )
3769        .unwrap();
3770        let entries = expected
3771            .into_iter()
3772            .map(|((kind, scope_id, node_id), samples)| {
3773                let groups = relations
3774                    .records
3775                    .iter()
3776                    .filter(|relation| samples.contains(&relation.sample_id))
3777                    .filter_map(|relation| relation.group_id.clone())
3778                    .collect::<BTreeSet<_>>()
3779                    .into_iter()
3780                    .collect();
3781                TrainingInfluenceEntry {
3782                    kind,
3783                    scope_id,
3784                    node_id,
3785                    physical_sample_ids: samples.into_iter().collect(),
3786                    origin_sample_ids: Vec::new(),
3787                    group_ids: groups,
3788                }
3789            })
3790            .collect();
3791        let mut manifest = TrainingInfluenceManifest {
3792            schema_version: TRAINING_INFLUENCE_MANIFEST_SCHEMA_VERSION,
3793            relation_fingerprint: relations.fingerprint().unwrap(),
3794            entries,
3795            manifest_fingerprint: zero_fingerprint(),
3796        };
3797        manifest.manifest_fingerprint = manifest.compute_fingerprint().unwrap();
3798        manifest
3799    }
3800
3801    fn resign_manifest(manifest: &mut TrainingInfluenceManifest) {
3802        manifest.manifest_fingerprint = zero_fingerprint();
3803        manifest.manifest_fingerprint = manifest.compute_fingerprint().unwrap();
3804    }
3805
3806    fn early_stopping_requirements() -> Vec<ControllerInfluenceRequirement> {
3807        vec![
3808            ControllerInfluenceRequirement {
3809                node_id: NodeId::new("model:base").unwrap(),
3810                kind: TrainingInfluenceKind::EarlyStopping,
3811                scope_id: "early:fold:0".to_string(),
3812                phase: Phase::FitCv,
3813                fold_id: Some(FoldId::new("fold:0").unwrap()),
3814                physical_sample_ids: vec![SampleId::new("sample:3").unwrap()],
3815            },
3816            ControllerInfluenceRequirement {
3817                node_id: NodeId::new("model:base").unwrap(),
3818                kind: TrainingInfluenceKind::EarlyStopping,
3819                scope_id: "early:fold:1".to_string(),
3820                phase: Phase::FitCv,
3821                fold_id: Some(FoldId::new("fold:1").unwrap()),
3822                physical_sample_ids: vec![SampleId::new("sample:1").unwrap()],
3823            },
3824            ControllerInfluenceRequirement {
3825                node_id: NodeId::new("model:base").unwrap(),
3826                kind: TrainingInfluenceKind::EarlyStopping,
3827                scope_id: "early:refit".to_string(),
3828                phase: Phase::Refit,
3829                fold_id: None,
3830                physical_sample_ids: vec![SampleId::new("sample:1").unwrap()],
3831            },
3832        ]
3833    }
3834
3835    fn full_scope_requirements(
3836        kind: TrainingInfluenceKind,
3837        prefix: &str,
3838    ) -> Vec<ControllerInfluenceRequirement> {
3839        vec![
3840            ControllerInfluenceRequirement {
3841                node_id: NodeId::new("model:base").unwrap(),
3842                kind,
3843                scope_id: format!("{prefix}:fold:0"),
3844                phase: Phase::FitCv,
3845                fold_id: Some(FoldId::new("fold:0").unwrap()),
3846                physical_sample_ids: vec![
3847                    SampleId::new("sample:3").unwrap(),
3848                    SampleId::new("sample:4").unwrap(),
3849                ],
3850            },
3851            ControllerInfluenceRequirement {
3852                node_id: NodeId::new("model:base").unwrap(),
3853                kind,
3854                scope_id: format!("{prefix}:fold:1"),
3855                phase: Phase::FitCv,
3856                fold_id: Some(FoldId::new("fold:1").unwrap()),
3857                physical_sample_ids: vec![
3858                    SampleId::new("sample:1").unwrap(),
3859                    SampleId::new("sample:2").unwrap(),
3860                ],
3861            },
3862            ControllerInfluenceRequirement {
3863                node_id: NodeId::new("model:base").unwrap(),
3864                kind,
3865                scope_id: format!("{prefix}:refit"),
3866                phase: Phase::Refit,
3867                fold_id: None,
3868                physical_sample_ids: (1..=4)
3869                    .map(|index| SampleId::new(format!("sample:{index}")).unwrap())
3870                    .collect(),
3871            },
3872        ]
3873    }
3874
3875    #[test]
3876    fn influence_evidence_is_capability_complete_and_relation_closed() {
3877        let relations = relations();
3878        let request = request_for_relations(&relations);
3879        let projection = request.project().unwrap();
3880        let manifest = influence_manifest(&request, &projection, &relations);
3881        assert_eq!(manifest.entries.len(), 7);
3882        manifest
3883            .validate_for_projection(&projection, &request, &relations)
3884            .unwrap();
3885
3886        let mut missing = manifest.clone();
3887        missing.entries.remove(1);
3888        resign_manifest(&mut missing);
3889        assert!(missing
3890            .validate_for_projection(&projection, &request, &relations)
3891            .unwrap_err()
3892            .to_string()
3893            .contains("phase scopes"));
3894
3895        let mut wrong_group = manifest;
3896        wrong_group.entries[0].group_ids.pop();
3897        resign_manifest(&mut wrong_group);
3898        assert!(wrong_group
3899            .validate_for_projection(&projection, &request, &relations)
3900            .unwrap_err()
3901            .to_string()
3902            .contains("group closure"));
3903    }
3904
3905    #[test]
3906    fn influence_capabilities_require_every_fold_and_refit_scope_and_refuse_extra() {
3907        let relations = relations();
3908        let mut request = request_for_relations(&relations);
3909        let model_manifest = request
3910            .controller_manifests
3911            .iter_mut()
3912            .find(|manifest| manifest.operator_kind == NodeKind::Model)
3913            .unwrap();
3914        model_manifest
3915            .capabilities
3916            .insert(ControllerCapability::UsesEarlyStopping);
3917        request.influence_requirements = early_stopping_requirements();
3918        resign_request(&mut request);
3919        let projection = request.project().unwrap();
3920        let mut manifest = influence_manifest(&request, &projection, &relations);
3921        assert_eq!(
3922            manifest
3923                .entries
3924                .iter()
3925                .filter(|entry| entry.kind == TrainingInfluenceKind::EarlyStopping)
3926                .count(),
3927            3
3928        );
3929        let removed = manifest
3930            .entries
3931            .iter()
3932            .position(|entry| entry.kind == TrainingInfluenceKind::EarlyStopping)
3933            .unwrap();
3934        manifest.entries.remove(removed);
3935        resign_manifest(&mut manifest);
3936        assert!(manifest
3937            .validate_for_projection(&projection, &request, &relations)
3938            .unwrap_err()
3939            .to_string()
3940            .contains("phase scopes"));
3941
3942        let mut leaked_request = request.clone();
3943        leaked_request.influence_requirements[0].physical_sample_ids =
3944            vec![SampleId::new("sample:1").unwrap()];
3945        resign_request(&mut leaked_request);
3946        assert!(leaked_request
3947            .project()
3948            .unwrap_err()
3949            .to_string()
3950            .contains("outer validation"));
3951
3952        let base_request = request_for_relations(&relations);
3953        let base_projection = base_request.project().unwrap();
3954        let mut extra = influence_manifest(&base_request, &base_projection, &relations);
3955        let model_entry = extra
3956            .entries
3957            .iter()
3958            .find(|entry| {
3959                entry.kind == TrainingInfluenceKind::ModelFit
3960                    && entry.scope_id.starts_with("fit_cv:")
3961            })
3962            .unwrap()
3963            .clone();
3964        extra.entries.push(TrainingInfluenceEntry {
3965            kind: TrainingInfluenceKind::EarlyStopping,
3966            ..model_entry
3967        });
3968        extra.entries.sort_by(|left, right| {
3969            (left.kind, left.scope_id.as_str(), left.node_id.as_ref()).cmp(&(
3970                right.kind,
3971                right.scope_id.as_str(),
3972                right.node_id.as_ref(),
3973            ))
3974        });
3975        resign_manifest(&mut extra);
3976        assert!(extra
3977            .validate_for_projection(&base_projection, &base_request, &relations)
3978            .unwrap_err()
3979            .to_string()
3980            .contains("undeclared coordinate"));
3981    }
3982
3983    #[test]
3984    fn influence_requirement_cannot_claim_a_capability_the_controller_lacks() {
3985        let mut request = request();
3986        request.influence_requirements = early_stopping_requirements();
3987        resign_request(&mut request);
3988        assert!(request
3989            .project()
3990            .unwrap_err()
3991            .to_string()
3992            .contains("not required by active controller capabilities"));
3993    }
3994
3995    #[test]
3996    fn influence_capability_matrix_covers_weights_internal_tuning_and_trained_aggregation() {
3997        let relations = relations();
3998        for (capability, kind, prefix) in [
3999            (
4000                ControllerCapability::UsesTrainingWeights,
4001                TrainingInfluenceKind::WeightingResampling,
4002                "weighting",
4003            ),
4004            (
4005                ControllerCapability::PerformsInternalTuning,
4006                TrainingInfluenceKind::HpoSelection,
4007                "internal_hpo",
4008            ),
4009        ] {
4010            let mut request = request_for_relations(&relations);
4011            let model = request
4012                .controller_manifests
4013                .iter_mut()
4014                .find(|manifest| manifest.operator_kind == NodeKind::Model)
4015                .unwrap();
4016            model.capabilities.insert(capability);
4017            if capability == ControllerCapability::UsesTrainingWeights {
4018                model
4019                    .capabilities
4020                    .insert(ControllerCapability::SupportsSampleWeights);
4021            }
4022            request.influence_requirements = full_scope_requirements(kind, prefix);
4023            resign_request(&mut request);
4024            let projection = request.project().unwrap();
4025            let mut manifest = influence_manifest(&request, &projection, &relations);
4026            assert_eq!(
4027                manifest
4028                    .entries
4029                    .iter()
4030                    .filter(|entry| entry.kind == kind && entry.node_id.is_some())
4031                    .count(),
4032                3
4033            );
4034            manifest
4035                .validate_for_projection(&projection, &request, &relations)
4036                .unwrap();
4037            let removed = manifest
4038                .entries
4039                .iter()
4040                .position(|entry| entry.kind == kind && entry.node_id.is_some())
4041                .unwrap();
4042            manifest.entries.remove(removed);
4043            resign_manifest(&mut manifest);
4044            assert!(manifest
4045                .validate_for_projection(&projection, &request, &relations)
4046                .unwrap_err()
4047                .to_string()
4048                .contains("phase scopes"));
4049        }
4050
4051        let mut aggregation = request_for_relations(&relations);
4052        let model = aggregation
4053            .controller_manifests
4054            .iter_mut()
4055            .find(|manifest| manifest.operator_kind == NodeKind::Model)
4056            .unwrap();
4057        model
4058            .capabilities
4059            .insert(ControllerCapability::TrainsAggregation);
4060        resign_request(&mut aggregation);
4061        assert!(aggregation.validate().is_err());
4062
4063        let model = aggregation
4064            .controller_manifests
4065            .iter_mut()
4066            .find(|manifest| manifest.operator_kind == NodeKind::Model)
4067            .unwrap();
4068        model
4069            .capabilities
4070            .insert(ControllerCapability::AggregatesPredictions);
4071        resign_request(&mut aggregation);
4072        let projection = aggregation.project().unwrap();
4073        let mut manifest = influence_manifest(&aggregation, &projection, &relations);
4074        assert!(manifest.entries.iter().any(|entry| {
4075            entry
4076                .node_id
4077                .as_ref()
4078                .is_some_and(|node_id| node_id.as_str() == "model:base")
4079                && entry.kind == TrainingInfluenceKind::TrainedMetaAggregation
4080        }));
4081        assert!(!manifest.entries.iter().any(|entry| {
4082            entry
4083                .node_id
4084                .as_ref()
4085                .is_some_and(|node_id| node_id.as_str() == "model:base")
4086                && entry.kind == TrainingInfluenceKind::ModelFit
4087        }));
4088        manifest
4089            .validate_for_projection(&projection, &aggregation, &relations)
4090            .unwrap();
4091        let removed = manifest
4092            .entries
4093            .iter()
4094            .position(|entry| {
4095                entry
4096                    .node_id
4097                    .as_ref()
4098                    .is_some_and(|node_id| node_id.as_str() == "model:base")
4099                    && entry.kind == TrainingInfluenceKind::TrainedMetaAggregation
4100            })
4101            .unwrap();
4102        manifest.entries.remove(removed);
4103        resign_manifest(&mut manifest);
4104        assert!(manifest
4105            .validate_for_projection(&projection, &aggregation, &relations)
4106            .is_err());
4107    }
4108
4109    #[test]
4110    fn parallel_scheduler_is_bound_to_thread_or_process_capabilities() {
4111        let mut threaded = request();
4112        threaded.options.scheduler = TrainingSchedulerOptions {
4113            kind: TrainingSchedulerKind::Parallel,
4114            backend: Some(TrainingSchedulerBackend::Threads),
4115            workers: 2,
4116        };
4117        threaded.options.resources.cpu_threads = 2;
4118        resign_request(&mut threaded);
4119        threaded.validate().unwrap();
4120
4121        let mut unsafe_threads = threaded.clone();
4122        unsafe_threads
4123            .controller_manifests
4124            .iter_mut()
4125            .find(|manifest| manifest.operator_kind == NodeKind::Model)
4126            .unwrap()
4127            .capabilities
4128            .remove(&ControllerCapability::ThreadSafe);
4129        resign_request(&mut unsafe_threads);
4130        assert!(unsafe_threads
4131            .validate()
4132            .unwrap_err()
4133            .to_string()
4134            .contains("thread_safe"));
4135
4136        let mut gil_threads = threaded.clone();
4137        gil_threads
4138            .controller_manifests
4139            .iter_mut()
4140            .find(|manifest| manifest.operator_kind == NodeKind::Model)
4141            .unwrap()
4142            .capabilities
4143            .insert(ControllerCapability::NeedsPythonGil);
4144        resign_request(&mut gil_threads);
4145        assert!(gil_threads
4146            .validate()
4147            .unwrap_err()
4148            .to_string()
4149            .contains("needs_python_gil"));
4150
4151        gil_threads.options.scheduler.backend = Some(TrainingSchedulerBackend::Processes);
4152        resign_request(&mut gil_threads);
4153        gil_threads.validate().unwrap();
4154    }
4155
4156    #[test]
4157    fn portable_required_artifact_mode_rejects_host_only_controller() {
4158        let mut request = request();
4159        request.options.artifacts.fitted_artifacts = FittedArtifactMode::PortableRequired;
4160        request
4161            .controller_manifests
4162            .iter_mut()
4163            .find(|manifest| manifest.operator_kind == NodeKind::Model)
4164            .unwrap()
4165            .artifact_policy = ArtifactPolicy::HostOnly;
4166        resign_request(&mut request);
4167        assert!(request
4168            .validate()
4169            .unwrap_err()
4170            .to_string()
4171            .contains("host_only"));
4172        request.options.artifacts.fitted_artifacts = FittedArtifactMode::AllowHostSidecar;
4173        resign_request(&mut request);
4174        request.validate().unwrap();
4175    }
4176
4177    #[cfg(dag_ml_workspace_contract_fixtures)]
4178    fn package() -> PortablePredictorPackage {
4179        let outcome: Value = serde_json::from_str(include_str!(
4180            "../../../examples/fixtures/estimator/training_outcome_refit.v1.json"
4181        ))
4182        .unwrap();
4183        let effective_plan: ExecutionPlan =
4184            serde_json::from_value(outcome["effective_plan"].clone()).unwrap();
4185        let mut execution_bundle: ExecutionBundle =
4186            serde_json::from_value(outcome["execution_bundle"].clone()).unwrap();
4187        // The checked-in fixture is an R1 outcome carrying a readable V1
4188        // bundle.  This package fixture exercises the current V2 writer
4189        // contract, whose additive fields are all absent/empty here.
4190        execution_bundle.schema_version = crate::bundle::EXECUTION_BUNDLE_SCHEMA_VERSION;
4191        if let Some(scores) = &mut execution_bundle.scores {
4192            scores.schema_version = crate::metrics::SCORE_SET_SCHEMA_VERSION;
4193            for report in &mut scores.reports {
4194                report.producer_port = Some("oof".to_string());
4195            }
4196        }
4197        for cache in &mut execution_bundle.prediction_caches {
4198            cache.format = crate::bundle::BUNDLE_PREDICTION_CACHE_FORMAT.to_string();
4199        }
4200        let output_bindings = outcome["outputs"]
4201            .as_array()
4202            .unwrap()
4203            .iter()
4204            .map(|output| serde_json::from_value(output["binding"].clone()).unwrap())
4205            .collect::<Vec<OutputBinding>>();
4206        let training_influence: TrainingInfluenceManifest =
4207            serde_json::from_value(outcome["training_influence"].clone()).unwrap();
4208        let mut template = PredictorTemplate {
4209            graph: effective_plan.graph_plan.graph.clone(),
4210            campaign: effective_plan.campaign.clone(),
4211            controller_manifests: effective_plan.controller_manifests.clone(),
4212            template_fingerprint: zero_fingerprint(),
4213        };
4214        template.template_fingerprint = template.compute_fingerprint().unwrap();
4215        let mut data_identities = execution_bundle
4216            .data_requirements
4217            .iter()
4218            .map(|requirement| {
4219                let mut identity = TrainingDataIdentity {
4220                    requirement_key: requirement.key(),
4221                    schema_fingerprint: requirement.schema_fingerprint.clone(),
4222                    plan_fingerprint: requirement.plan_fingerprint.clone(),
4223                    relation_fingerprint: requirement.relation_fingerprint.clone().unwrap(),
4224                    data_content_fingerprint: "3".repeat(64),
4225                    target_content_fingerprint: "4".repeat(64),
4226                    identity_fingerprint: zero_fingerprint(),
4227                };
4228                identity.identity_fingerprint = identity.compute_fingerprint().unwrap();
4229                identity
4230            })
4231            .collect::<Vec<_>>();
4232        data_identities.sort_by(|left, right| left.requirement_key.cmp(&right.requirement_key));
4233        let closure = predictor_closure(
4234            &effective_plan,
4235            output_bindings.iter().map(|binding| &binding.node_id),
4236        )
4237        .unwrap();
4238        let mut artifact_bindings = execution_bundle
4239            .refit_artifacts
4240            .iter()
4241            .map(|record| PackageArtifactBinding {
4242                artifact_id: record.artifact.id.clone(),
4243                load_mode: ArtifactLoadMode::HostSidecar,
4244            })
4245            .collect::<Vec<_>>();
4246        artifact_bindings.sort_by(|left, right| left.artifact_id.cmp(&right.artifact_id));
4247        let output_binding_fingerprints = output_bindings
4248            .iter()
4249            .map(|binding| binding.binding_fingerprint.clone())
4250            .collect::<Vec<_>>();
4251        let execution_bundle_fingerprint =
4252            tcv1_fingerprint(&execution_bundle, "test execution bundle").unwrap();
4253        let data_identities_fingerprint =
4254            tcv1_fingerprint(&data_identities, "test data identities").unwrap();
4255        let mut package = PortablePredictorPackage {
4256            schema_version: PORTABLE_PREDICTOR_PACKAGE_SCHEMA_VERSION,
4257            package_id: "predictor:package.test".to_string(),
4258            template,
4259            training_request_fingerprint: "5".repeat(64),
4260            training_outcome: TrainingOutcomeRef {
4261                outcome_id: outcome["outcome_id"].as_str().unwrap().to_string(),
4262                outcome_fingerprint: outcome["outcome_fingerprint"].as_str().unwrap().to_string(),
4263                pre_conformal_outcome_fingerprint: None,
4264                training_request_fingerprint: "5".repeat(64),
4265                effective_plan_fingerprint: outcome["effective_plan_fingerprint"]
4266                    .as_str()
4267                    .unwrap()
4268                    .to_string(),
4269                execution_bundle_id: execution_bundle.bundle_id.clone(),
4270                execution_bundle_fingerprint,
4271                output_binding_fingerprints,
4272                training_influence_fingerprint: training_influence.manifest_fingerprint.clone(),
4273                data_identities_fingerprint,
4274            },
4275            effective_plan,
4276            execution_bundle,
4277            conformal_calibration: None,
4278            conformal_calibration_replay: None,
4279            output_bindings,
4280            predictor_node_ids: closure.into_iter().collect(),
4281            training_influence,
4282            data_identities,
4283            fitted_artifact_mode: FittedArtifactMode::AllowHostSidecar,
4284            artifact_bindings,
4285            package_fingerprint: zero_fingerprint(),
4286        };
4287        package.package_fingerprint = package.compute_fingerprint().unwrap();
4288        package
4289    }
4290
4291    #[cfg(dag_ml_workspace_contract_fixtures)]
4292    #[test]
4293    fn portable_package_round_trips_loads_sidecar_and_rejects_tamper_and_future() {
4294        let package = package();
4295        package.validate().unwrap();
4296        let json = serde_json::to_string(&package).unwrap();
4297        PortablePredictorPackage::from_json(&json).unwrap();
4298
4299        let loaded = package
4300            .clone()
4301            .load_with(|record| Ok(format!("handle:{}", record.artifact.id)))
4302            .unwrap();
4303        let first = &package.artifact_bindings[0].artifact_id;
4304        assert_eq!(loaded.artifact(first).unwrap(), &format!("handle:{first}"));
4305
4306        let mut missing_handles = BTreeMap::new();
4307        missing_handles.insert(first.clone(), "only-one".to_string());
4308        assert!(LoadedPredictor::new(package.clone(), missing_handles).is_err());
4309
4310        let mut tampered = package.clone();
4311        tampered.output_bindings[0].target_space = "tampered".to_string();
4312        assert!(tampered.validate().is_err());
4313
4314        let mut future = package.clone();
4315        future.schema_version += 1;
4316        future.package_fingerprint = zero_fingerprint();
4317        future.package_fingerprint = future.compute_fingerprint().unwrap();
4318        assert!(future.validate().is_err());
4319
4320        let mut binary64 = serde_json::to_value(package).unwrap();
4321        binary64["effective_plan"]["campaign"]["root_seed"] = json!(12345.0);
4322        assert!(serde_json::from_value::<PortablePredictorPackage>(binary64).is_err());
4323    }
4324
4325    #[cfg(dag_ml_workspace_contract_fixtures)]
4326    #[test]
4327    fn portable_package_strict_parser_rejects_duplicate_and_nfc_colliding_keys() {
4328        let json = serde_json::to_string(&package()).unwrap();
4329        let duplicate = json.replacen(
4330            "\"schema_version\":1",
4331            "\"schema_version\":1,\"schema_version\":1",
4332            1,
4333        );
4334        assert!(PortablePredictorPackage::from_json(&duplicate)
4335            .unwrap_err()
4336            .to_string()
4337            .contains("duplicate JSON object key"));
4338
4339        let collision = json.replacen(
4340            "\"metadata\":{}",
4341            "\"metadata\":{\"é\":1,\"e\\u0301\":2}",
4342            1,
4343        );
4344        assert!(PortablePredictorPackage::from_json(&collision)
4345            .unwrap_err()
4346            .to_string()
4347            .contains("NFC-colliding"));
4348    }
4349
4350    #[cfg(dag_ml_workspace_contract_fixtures)]
4351    #[test]
4352    fn portable_package_rejects_refingerprinted_crosslink_and_relation_drift() {
4353        let mut plan_drift = package();
4354        plan_drift.training_outcome.effective_plan_fingerprint = "f".repeat(64);
4355        plan_drift.package_fingerprint = zero_fingerprint();
4356        plan_drift.package_fingerprint = plan_drift.compute_fingerprint().unwrap();
4357        assert!(plan_drift
4358            .validate()
4359            .unwrap_err()
4360            .to_string()
4361            .contains("effective plan fingerprint"));
4362
4363        let mut binding_drift = package();
4364        binding_drift.output_bindings[0].target_space = "other".to_string();
4365        binding_drift.output_bindings[0].binding_fingerprint = zero_fingerprint();
4366        binding_drift.output_bindings[0].binding_fingerprint = binding_drift.output_bindings[0]
4367            .compute_fingerprint()
4368            .unwrap();
4369        binding_drift.package_fingerprint = zero_fingerprint();
4370        binding_drift.package_fingerprint = binding_drift.compute_fingerprint().unwrap();
4371        assert!(binding_drift
4372            .validate()
4373            .unwrap_err()
4374            .to_string()
4375            .contains("output bindings are not cross-linked"));
4376
4377        let mut relation_drift = package();
4378        relation_drift.data_identities[0].relation_fingerprint = "e".repeat(64);
4379        relation_drift.data_identities[0].identity_fingerprint = zero_fingerprint();
4380        relation_drift.data_identities[0].identity_fingerprint = relation_drift.data_identities[0]
4381            .compute_fingerprint()
4382            .unwrap();
4383        relation_drift.package_fingerprint = zero_fingerprint();
4384        relation_drift.package_fingerprint = relation_drift.compute_fingerprint().unwrap();
4385        assert!(relation_drift
4386            .validate()
4387            .unwrap_err()
4388            .to_string()
4389            .contains("bundle fingerprints"));
4390
4391        let mut content_drift = package();
4392        content_drift.data_identities[0].data_content_fingerprint = "d".repeat(64);
4393        content_drift.data_identities[0].identity_fingerprint = zero_fingerprint();
4394        content_drift.data_identities[0].identity_fingerprint = content_drift.data_identities[0]
4395            .compute_fingerprint()
4396            .unwrap();
4397        content_drift.package_fingerprint = zero_fingerprint();
4398        content_drift.package_fingerprint = content_drift.compute_fingerprint().unwrap();
4399        assert!(content_drift
4400            .validate()
4401            .unwrap_err()
4402            .to_string()
4403            .contains("data identity content"));
4404
4405        let mut bundle_drift = package();
4406        bundle_drift
4407            .execution_bundle
4408            .metadata
4409            .insert("same_id_drift".to_string(), json!(true));
4410        bundle_drift.package_fingerprint = zero_fingerprint();
4411        bundle_drift.package_fingerprint = bundle_drift.compute_fingerprint().unwrap();
4412        assert!(bundle_drift
4413            .validate()
4414            .unwrap_err()
4415            .to_string()
4416            .contains("execution bundle content"));
4417    }
4418
4419    #[cfg(dag_ml_workspace_contract_fixtures)]
4420    #[test]
4421    fn portable_required_package_has_no_host_sidecar_subset() {
4422        let mut package = package();
4423        package.fitted_artifact_mode = FittedArtifactMode::PortableRequired;
4424        for binding in &mut package.artifact_bindings {
4425            binding.load_mode = ArtifactLoadMode::NativePortable;
4426        }
4427        package.package_fingerprint = zero_fingerprint();
4428        package.package_fingerprint = package.compute_fingerprint().unwrap();
4429        package.validate().unwrap();
4430        let loaded = LoadedPredictor::<String>::new(package, BTreeMap::new()).unwrap();
4431        assert!(loaded.artifacts.is_empty());
4432    }
4433
4434    #[cfg(dag_ml_workspace_contract_fixtures)]
4435    #[test]
4436    fn package_refuses_runtime_handle_shape_even_when_nested_in_metadata() {
4437        for payload in [
4438            json!({"handle": 9, "owner_controller": "controller:model.mock"}),
4439            json!({"nested": {"model_handle": 9}}),
4440            json!({"nested": [{"runtime_handles": [9]}]}),
4441        ] {
4442            let mut package = package();
4443            package
4444                .execution_bundle
4445                .metadata
4446                .insert("forbidden".to_string(), payload);
4447            package.training_outcome.execution_bundle_fingerprint =
4448                tcv1_fingerprint(&package.execution_bundle, "runtime-handle test bundle").unwrap();
4449            package.package_fingerprint = zero_fingerprint();
4450            package.package_fingerprint = package.compute_fingerprint().unwrap();
4451            assert!(package
4452                .validate()
4453                .unwrap_err()
4454                .to_string()
4455                .contains("runtime handles"));
4456        }
4457    }
4458
4459    #[cfg(dag_ml_workspace_contract_fixtures)]
4460    #[test]
4461    fn portable_w0_output_binding_and_influence_fingerprints_match_production_tcv1() {
4462        let package = package();
4463        for binding in &package.output_bindings {
4464            assert_eq!(
4465                binding.binding_fingerprint,
4466                binding.compute_fingerprint().unwrap()
4467            );
4468        }
4469        assert_eq!(
4470            package.training_influence.manifest_fingerprint,
4471            package.training_influence.compute_fingerprint().unwrap()
4472        );
4473    }
4474
4475    #[cfg(dag_ml_workspace_contract_fixtures)]
4476    #[test]
4477    fn portable_package_accepts_multi_scope_base_influence_per_node() {
4478        let mut package = package();
4479        let base_kinds = [
4480            TrainingInfluenceKind::TransformFit,
4481            TrainingInfluenceKind::ModelFit,
4482            TrainingInfluenceKind::HpoSelection,
4483            TrainingInfluenceKind::TrainedMetaAggregation,
4484        ]
4485        .into_iter()
4486        .collect::<BTreeSet<_>>();
4487        let mut entries = Vec::new();
4488        for entry in package.training_influence.entries.clone() {
4489            if entry.node_id.is_some() && base_kinds.contains(&entry.kind) {
4490                for suffix in ["fit_cv:fold:0", "fit_cv:fold:1", "refit:full"] {
4491                    let mut scoped = entry.clone();
4492                    scoped.scope_id = format!("{suffix}:{}", entry.scope_id);
4493                    entries.push(scoped);
4494                }
4495            } else {
4496                entries.push(entry);
4497            }
4498        }
4499        entries.sort_by(|left, right| {
4500            (left.kind, &left.scope_id, &left.node_id).cmp(&(
4501                right.kind,
4502                &right.scope_id,
4503                &right.node_id,
4504            ))
4505        });
4506        package.training_influence.entries = entries;
4507        package.training_influence.manifest_fingerprint = zero_fingerprint();
4508        package.training_influence.manifest_fingerprint =
4509            package.training_influence.compute_fingerprint().unwrap();
4510        package.training_outcome.training_influence_fingerprint =
4511            package.training_influence.manifest_fingerprint.clone();
4512        package.package_fingerprint = zero_fingerprint();
4513        package.package_fingerprint = package.compute_fingerprint().unwrap();
4514        package.validate().unwrap();
4515    }
4516
4517    #[cfg(dag_ml_workspace_contract_fixtures)]
4518    #[test]
4519    fn controller_id_import_remains_the_same_public_type() {
4520        // Guards the package/template key type against accidental string-only
4521        // drift while keeping this test module's import exercised.
4522        let id = ControllerId::new("controller:model.mock").unwrap();
4523        assert!(package().template.controller_manifests.contains_key(&id));
4524    }
4525
4526    #[cfg(dag_ml_workspace_contract_fixtures)]
4527    #[test]
4528    fn committed_w1_fixtures_match_rust_and_independent_tcv1_oracle() {
4529        let refit_json =
4530            include_str!("../../../examples/fixtures/training/training_request_refit.v1.json");
4531        let refit = TrainingRequest::from_json(refit_json).unwrap();
4532        let no_refit = TrainingRequest::from_json(include_str!(
4533            "../../../examples/fixtures/training/training_request_no_refit.v1.json"
4534        ))
4535        .unwrap();
4536        let active_influence = TrainingRequest::from_json(include_str!(
4537            "../../../examples/fixtures/training/training_request_active_influence.v1.json"
4538        ))
4539        .unwrap();
4540        let package_request = TrainingRequest::from_json(include_str!(
4541            "../../../examples/fixtures/training/training_request_package_refit.v1.json"
4542        ))
4543        .unwrap();
4544        assert!(refit.options.refit);
4545        assert!(!no_refit.options.refit);
4546        assert_eq!(active_influence.influence_requirements.len(), 6);
4547
4548        let package_json =
4549            include_str!("../../../examples/fixtures/training/portable_predictor_package.v1.json");
4550        let package = PortablePredictorPackage::from_json(package_json).unwrap();
4551        assert_eq!(
4552            package.training_request_fingerprint,
4553            package_request.request_fingerprint
4554        );
4555        assert_eq!(package.data_identities, package_request.data_identities);
4556        let namespace = CacheNamespace::from_json(include_str!(
4557            "../../../examples/fixtures/training/cache_namespace_fit_cv.v1.json"
4558        ))
4559        .unwrap();
4560        let identity = package
4561            .data_identities
4562            .iter()
4563            .find(|identity| identity.requirement_key == namespace.data_requirement_key)
4564            .unwrap();
4565        namespace.validate_for_identity(identity).unwrap();
4566
4567        let projection: ParameterProjection = serde_json::from_str(include_str!(
4568            "../../../examples/fixtures/training/parameter_projection_empty.v1.json"
4569        ))
4570        .unwrap();
4571        projection.validate().unwrap();
4572
4573        let negatives: serde_json::Value = serde_json::from_str(include_str!(
4574            "../../../examples/fixtures/training/negative_cases.v1.json"
4575        ))
4576        .unwrap();
4577        for case in negatives["cases"].as_array().unwrap() {
4578            let document = serde_json::to_string(&case["document"]).unwrap();
4579            let error = match case["contract"].as_str().unwrap() {
4580                "cache_namespace" => CacheNamespace::from_json(&document).unwrap_err(),
4581                "portable_predictor_package" => {
4582                    PortablePredictorPackage::from_json(&document).unwrap_err()
4583                }
4584                "training_outcome" => {
4585                    crate::training_runtime::TrainingOutcome::from_json(&document).unwrap_err()
4586                }
4587                "training_request" => TrainingRequest::from_json(&document).unwrap_err(),
4588                other => panic!("unknown negative contract {other}"),
4589            };
4590            assert!(
4591                error
4592                    .to_string()
4593                    .contains(case["expected_error"].as_str().unwrap()),
4594                "{}: {error}",
4595                case["id"]
4596            );
4597        }
4598    }
4599
4600    #[test]
4601    fn projection_strict_parsers_reject_duplicate_and_nfc_colliding_keys() {
4602        let mut projection = ParameterProjection {
4603            schema_version: PARAMETER_PROJECTION_SCHEMA_VERSION,
4604            nodes: BTreeMap::new(),
4605            requires_recompile: false,
4606            structural_patch_count: 0,
4607            patches_fingerprint: tcv1_fingerprint(&Vec::<ParameterPatch>::new(), "test patches")
4608                .unwrap(),
4609            projection_fingerprint: zero_fingerprint(),
4610        };
4611        projection.projection_fingerprint = projection.compute_fingerprint().unwrap();
4612        let parameter_json = serde_json::to_string(&projection).unwrap();
4613        ParameterProjection::from_json(&parameter_json).unwrap();
4614        let duplicate = parameter_json.replacen(
4615            "\"schema_version\":1",
4616            "\"schema_version\":1,\"schema_version\":1",
4617            1,
4618        );
4619        assert!(ParameterProjection::from_json(&duplicate)
4620            .unwrap_err()
4621            .to_string()
4622            .contains("duplicate JSON object key"));
4623        let collision = parameter_json.replacen('{', "{\"é\":1,\"e\\u0301\":2,", 1);
4624        assert!(ParameterProjection::from_json(&collision)
4625            .unwrap_err()
4626            .to_string()
4627            .contains("NFC-colliding"));
4628
4629        let request = request();
4630        let projection = request.project().unwrap();
4631        let projection_json = serde_json::to_string(&projection).unwrap();
4632        TrainingContractProjection::from_json(&projection_json).unwrap();
4633        let duplicate = projection_json.replacen(
4634            "\"request_id\":",
4635            "\"request_id\":\"duplicate\",\"request_id\":",
4636            1,
4637        );
4638        assert!(TrainingContractProjection::from_json(&duplicate)
4639            .unwrap_err()
4640            .to_string()
4641            .contains("duplicate JSON object key"));
4642        let collision = projection_json.replacen('{', "{\"é\":1,\"e\\u0301\":2,", 1);
4643        assert!(TrainingContractProjection::from_json(&collision)
4644            .unwrap_err()
4645            .to_string()
4646            .contains("NFC-colliding"));
4647
4648        for path in [
4649            &["plan", "graph_plan", "graph"][..],
4650            &["plan", "campaign"][..],
4651        ] {
4652            let mut unknown: serde_json::Value = serde_json::from_str(&projection_json).unwrap();
4653            let mut parent = &mut unknown;
4654            for segment in path {
4655                parent = &mut parent[*segment];
4656            }
4657            parent["unknown_projection_field"] = json!(true);
4658            let error =
4659                TrainingContractProjection::from_json(&serde_json::to_string(&unknown).unwrap())
4660                    .unwrap_err();
4661            let expected_path = format!("{}.unknown_projection_field", path.join("."));
4662            assert!(error.to_string().contains("unknown field"), "{error}");
4663            assert!(error.to_string().contains(&expected_path), "{error}");
4664        }
4665    }
4666}