1use 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";
39pub 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
54fn 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#[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 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 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#[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 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 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
657pub 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 #[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 ®istry,
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#[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#[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 #[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
1521 pub conformal_calibration: Option<ConformalCalibration>,
1522 #[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 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
1974pub 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 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 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 #[test]
3209 fn required_nullable_fields_reject_omission_but_accept_explicit_null() {
3210 let base = request();
3211
3212 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 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 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 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 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 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 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(¶meter_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}