1use super::*;
3
4#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
5pub struct DataMaterializationRequest {
6 pub run_id: RunId,
7 pub node_id: NodeId,
8 pub input_name: String,
9 pub phase: Phase,
10 pub variant_id: Option<VariantId>,
11 pub fold_id: Option<FoldId>,
12 pub binding: crate::data::DataBinding,
13}
14
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16pub struct DataProviderViewSpec {
17 #[serde(default)]
18 pub sample_ids: Option<Vec<SampleId>>,
19 pub partition: DataRequestPartition,
20 #[serde(default)]
21 pub fold_id: Option<FoldId>,
22 #[serde(default)]
23 pub source_ids: Option<Vec<String>>,
24 #[serde(default)]
25 pub columns: Option<Vec<String>>,
26 pub include_augmented: bool,
27 pub include_excluded: bool,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub branch_view: Option<crate::data::BranchViewPlan>,
30 #[serde(default)]
31 pub extra: BTreeMap<String, serde_json::Value>,
32}
33
34pub const DATA_OUTPUT_PROVENANCE_KEY: &str = "dag_ml_output";
35pub const DATA_OUTPUT_PROVENANCE_SCHEMA_VERSION: u32 = 1;
36pub const DATA_OUTPUT_PROVENANCE_SCHEMA_ID: &str =
37 "https://github.com/GBeurier/dag-ml/schemas/data_output_provenance.v1.schema.json";
38pub const NODE_TASK_SCHEMA_VERSION: u32 = 1;
39pub const NODE_TASK_SCHEMA_ID: &str =
40 "https://github.com/GBeurier/dag-ml/schemas/node_task.v1.schema.json";
41pub const NODE_RESULT_SCHEMA_VERSION: u32 = 1;
42pub const NODE_RESULT_SCHEMA_ID: &str =
43 "https://github.com/GBeurier/dag-ml/schemas/node_result.v1.schema.json";
44
45pub(crate) fn default_data_output_provenance_schema_version() -> u32 {
46 DATA_OUTPUT_PROVENANCE_SCHEMA_VERSION
47}
48
49impl DataProviderViewSpec {
50 pub fn validate(&self) -> Result<()> {
51 validate_optional_ids("sample id", &self.sample_ids)?;
52 validate_optional_strings("source id", &self.source_ids)?;
53 validate_optional_strings("column", &self.columns)?;
54 match self.partition {
55 DataRequestPartition::FoldTrain | DataRequestPartition::FoldValidation => {
56 if self.sample_ids.is_some() && self.fold_id.is_none() {
57 return Err(DagMlError::RuntimeValidation(format!(
58 "data provider view {:?} with explicit sample ids requires a fold id",
59 self.partition
60 )));
61 }
62 }
63 DataRequestPartition::FullTrain | DataRequestPartition::Predict => {
64 if self.fold_id.is_some() {
65 return Err(DagMlError::RuntimeValidation(format!(
66 "data provider view {:?} must not carry a fold id",
67 self.partition
68 )));
69 }
70 }
71 }
72 for key in self.extra.keys() {
73 if key.trim().is_empty() {
74 return Err(DagMlError::RuntimeValidation(
75 "data provider view extra contains an empty key".to_string(),
76 ));
77 }
78 }
79 if let Some(branch_view) = &self.branch_view {
80 branch_view.validate()?;
81 }
82 self.output_provenance()?;
83 Ok(())
84 }
85
86 pub fn output_provenance(&self) -> Result<Option<DataOutputProvenance>> {
87 let Some(value) = self.extra.get(DATA_OUTPUT_PROVENANCE_KEY) else {
88 return Ok(None);
89 };
90 let provenance: DataOutputProvenance = serde_json::from_value(value.clone())?;
91 provenance.validate()?;
92 Ok(Some(provenance))
93 }
94}
95
96#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
97pub struct DataOutputProvenance {
98 #[serde(default = "default_data_output_provenance_schema_version")]
99 pub schema_version: u32,
100 pub producer_node: NodeId,
101 pub producer_port: String,
102 pub producer_phase: Phase,
103 #[serde(default)]
104 pub variant_id: Option<VariantId>,
105 #[serde(default)]
106 pub fold_id: Option<FoldId>,
107 #[serde(default)]
108 pub shape_plan_fingerprint: Option<String>,
109 #[serde(default)]
110 pub aggregation_policy_fingerprint: Option<String>,
111 #[serde(default)]
112 pub feature_namespace: Option<String>,
113 #[serde(default)]
114 pub feature_schema_fingerprint: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub representation_plan: Option<RepresentationPlan>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub representation_replay_manifest: Option<RepresentationReplayManifest>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub representation_compatibility: Option<RepresentationCompatibilityReport>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub relation_delta_fingerprint: Option<String>,
123 #[serde(default)]
124 pub shape_deltas: Vec<ShapeDelta>,
125}
126
127impl DataOutputProvenance {
128 pub fn validate(&self) -> Result<()> {
129 if self.schema_version != DATA_OUTPUT_PROVENANCE_SCHEMA_VERSION {
130 return Err(DagMlError::RuntimeValidation(format!(
131 "data output provenance for `{}` uses unsupported schema_version {}, expected {}",
132 self.producer_node, self.schema_version, DATA_OUTPUT_PROVENANCE_SCHEMA_VERSION
133 )));
134 }
135 if self.producer_port.trim().is_empty() {
136 return Err(DagMlError::RuntimeValidation(format!(
137 "data output provenance for `{}` has empty producer_port",
138 self.producer_node
139 )));
140 }
141 validate_optional_fingerprint(
142 "shape_plan_fingerprint",
143 &self.shape_plan_fingerprint,
144 &self.producer_node,
145 )?;
146 validate_optional_fingerprint(
147 "aggregation_policy_fingerprint",
148 &self.aggregation_policy_fingerprint,
149 &self.producer_node,
150 )?;
151 validate_optional_fingerprint(
152 "feature_schema_fingerprint",
153 &self.feature_schema_fingerprint,
154 &self.producer_node,
155 )?;
156 validate_optional_fingerprint(
157 "relation_delta_fingerprint",
158 &self.relation_delta_fingerprint,
159 &self.producer_node,
160 )?;
161 if let Some(representation_plan) = &self.representation_plan {
162 representation_plan.validate().map_err(|error| {
163 DagMlError::RuntimeValidation(format!(
164 "data output provenance for `{}` has invalid representation_plan: {error}",
165 self.producer_node
166 ))
167 })?;
168 }
169 if let Some(replay_manifest) = &self.representation_replay_manifest {
170 replay_manifest.validate().map_err(|error| {
171 DagMlError::RuntimeValidation(format!(
172 "data output provenance for `{}` has invalid representation_replay_manifest: {error}",
173 self.producer_node
174 ))
175 })?;
176 }
177 if let Some(report) = &self.representation_compatibility {
178 report.validate().map_err(|error| {
179 DagMlError::RuntimeValidation(format!(
180 "data output provenance for `{}` has invalid representation_compatibility: {error}",
181 self.producer_node
182 ))
183 })?;
184 }
185 if self
186 .feature_namespace
187 .as_ref()
188 .is_some_and(|namespace| namespace.trim().is_empty())
189 {
190 return Err(DagMlError::RuntimeValidation(format!(
191 "data output provenance for `{}` has empty feature_namespace",
192 self.producer_node
193 )));
194 }
195 for delta in &self.shape_deltas {
196 delta.validate()?;
197 if delta.node_id != self.producer_node {
198 return Err(DagMlError::RuntimeValidation(format!(
199 "data output provenance for `{}` contains shape delta for `{}`",
200 self.producer_node, delta.node_id
201 )));
202 }
203 }
204 if let Some(feature_schema_fingerprint) = &self.feature_schema_fingerprint {
205 if let Some(last_feature_delta) = self
206 .shape_deltas
207 .iter()
208 .rev()
209 .find(|delta| delta.kind == ShapeDeltaKind::Feature)
210 {
211 if &last_feature_delta.after_fingerprint != feature_schema_fingerprint {
212 return Err(DagMlError::RuntimeValidation(format!(
213 "data output provenance for `{}` has feature_schema_fingerprint `{feature_schema_fingerprint}` but last feature delta ends at `{}`",
214 self.producer_node, last_feature_delta.after_fingerprint
215 )));
216 }
217 }
218 }
219 Ok(())
220 }
221}
222
223pub(crate) fn validate_optional_fingerprint(
224 label: &str,
225 fingerprint: &Option<String>,
226 producer_node: &NodeId,
227) -> Result<()> {
228 let Some(fingerprint) = fingerprint else {
229 return Ok(());
230 };
231 if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
232 return Err(DagMlError::RuntimeValidation(format!(
233 "data output provenance for `{producer_node}` has invalid {label}"
234 )));
235 }
236 Ok(())
237}
238
239pub(crate) fn validate_optional_ids<T>(label: &str, values: &Option<Vec<T>>) -> Result<()>
240where
241 T: Ord + ToString,
242{
243 let Some(values) = values else {
244 return Ok(());
245 };
246 if values.is_empty() {
247 return Err(DagMlError::RuntimeValidation(format!(
248 "data provider view {label} list is empty"
249 )));
250 }
251 let mut seen = BTreeSet::new();
252 for value in values {
253 if !seen.insert(value) {
254 return Err(DagMlError::RuntimeValidation(format!(
255 "data provider view has duplicate {label} `{}`",
256 value.to_string()
257 )));
258 }
259 }
260 Ok(())
261}
262
263pub(crate) fn validate_optional_strings(label: &str, values: &Option<Vec<String>>) -> Result<()> {
264 let Some(values) = values else {
265 return Ok(());
266 };
267 if values.is_empty() {
268 return Err(DagMlError::RuntimeValidation(format!(
269 "data provider view {label} list is empty"
270 )));
271 }
272 let mut seen = BTreeSet::new();
273 for value in values {
274 if value.trim().is_empty() {
275 return Err(DagMlError::RuntimeValidation(format!(
276 "data provider view contains an empty {label}"
277 )));
278 }
279 if !seen.insert(value.as_str()) {
280 return Err(DagMlError::RuntimeValidation(format!(
281 "data provider view has duplicate {label} `{value}`"
282 )));
283 }
284 }
285 Ok(())
286}
287
288#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
289pub struct DataViewRequest {
290 pub run_id: RunId,
291 pub node_id: NodeId,
292 pub input_name: String,
293 pub phase: Phase,
294 pub variant_id: Option<VariantId>,
295 pub fold_id: Option<FoldId>,
296 pub binding: crate::data::DataBinding,
297 pub data_handle: HandleRef,
298 pub view: DataProviderViewSpec,
299}
300
301pub trait RuntimeDataProvider {
302 fn materialize(&self, request: &DataMaterializationRequest) -> Result<HandleRef>;
303 fn make_view(&self, request: &DataViewRequest) -> Result<HandleRef>;
304 fn training_data_identity(
310 &self,
311 _binding: &DataBinding,
312 ) -> Result<Option<crate::training::TrainingDataIdentity>> {
313 Ok(None)
314 }
315 fn coordinator_relations(&self, _binding: &DataBinding) -> Result<Option<SampleRelationSet>> {
316 Ok(None)
317 }
318
319 fn methods_pls_capability(&self) -> Result<()> {
324 Err(DagMlError::RuntimeValidation(
325 "runtime data provider does not implement the portable Methods PLS numeric view"
326 .to_string(),
327 ))
328 }
329
330 fn preflight_methods_pls(&self, request: &MethodsPlsDataRequest) -> Result<()> {
331 request.validate()?;
332 self.methods_pls_capability()
333 }
334
335 fn methods_pls_data(&self, _request: &MethodsPlsDataRequest) -> Result<MethodsPlsData> {
340 Err(DagMlError::RuntimeValidation(
341 "runtime data provider does not implement the portable Methods PLS numeric view"
342 .to_string(),
343 ))
344 }
345}
346
347#[derive(Clone, Debug, PartialEq)]
350pub struct MethodsPlsMatrix {
351 pub values: Vec<f64>,
352 pub rows: usize,
353 pub cols: usize,
354}
355
356impl MethodsPlsMatrix {
357 pub fn validate(&self, label: &str) -> Result<()> {
358 if self.rows == 0
359 || self.cols == 0
360 || self.rows.checked_mul(self.cols) != Some(self.values.len())
361 {
362 return Err(DagMlError::RuntimeValidation(format!(
363 "portable Methods PLS {label} matrix has invalid row-major dimensions"
364 )));
365 }
366 if self.values.iter().any(|value| !value.is_finite()) {
367 return Err(DagMlError::RuntimeValidation(format!(
368 "portable Methods PLS {label} matrix contains a non-finite value"
369 )));
370 }
371 Ok(())
372 }
373}
374
375#[derive(Clone, Debug, PartialEq)]
377pub struct MethodsPlsDataset {
378 pub sample_ids: Vec<SampleId>,
379 pub x: MethodsPlsMatrix,
380 pub y: Option<MethodsPlsMatrix>,
384 pub target_names: Vec<String>,
385}
386
387impl MethodsPlsDataset {
388 pub fn validate(&self, label: &str, require_targets: bool) -> Result<()> {
389 self.x.validate(&format!("{label}.x"))?;
390 if self.sample_ids.len() != self.x.rows {
391 return Err(DagMlError::RuntimeValidation(format!(
392 "portable Methods PLS {label} rows do not match sample identities"
393 )));
394 }
395 if self.target_names.is_empty()
396 || self.target_names.iter().any(|name| name.trim().is_empty())
397 {
398 return Err(DagMlError::RuntimeValidation(format!(
399 "portable Methods PLS {label} has invalid target names"
400 )));
401 }
402 match &self.y {
403 Some(y) => {
404 y.validate(&format!("{label}.y"))?;
405 if self.sample_ids.len() != y.rows || self.target_names.len() != y.cols {
406 return Err(DagMlError::RuntimeValidation(format!(
407 "portable Methods PLS {label} targets do not match sample identities or target names"
408 )));
409 }
410 }
411 None if require_targets => {
412 return Err(DagMlError::RuntimeValidation(format!(
413 "portable Methods PLS {label} requires targets for fitting or CV scoring"
414 )))
415 }
416 None => {}
417 }
418 let unique = self.sample_ids.iter().collect::<BTreeSet<_>>();
419 if unique.len() != self.sample_ids.len() {
420 return Err(DagMlError::RuntimeValidation(format!(
421 "portable Methods PLS {label} contains duplicate sample identities"
422 )));
423 }
424 Ok(())
425 }
426}
427
428#[derive(Clone, Debug, PartialEq)]
430pub struct MethodsPlsDataRequest {
431 pub node_id: NodeId,
432 pub phase: Phase,
433 pub variant_id: Option<VariantId>,
434 pub fold_id: Option<FoldId>,
435 pub binding: DataBinding,
438 pub identity: Option<crate::training::TrainingDataIdentity>,
446 pub fit_view: DataProviderViewSpec,
447 pub prediction_view: Option<DataProviderViewSpec>,
448}
449
450impl MethodsPlsDataRequest {
451 pub fn validate(&self) -> Result<()> {
452 self.binding.validate()?;
453 match &self.identity {
454 Some(identity) => {
455 identity.validate()?;
456 if identity.requirement_key
457 != crate::data::data_binding_requirement_key(
458 &self.binding.node_id,
459 &self.binding.input_name,
460 )
461 {
462 return Err(DagMlError::RuntimeValidation(
463 "portable Methods PLS identity is not bound to its data binding"
464 .to_string(),
465 ));
466 }
467 }
468 None if self.phase != Phase::Predict => {
469 return Err(DagMlError::RuntimeValidation(
470 "portable Methods PLS FIT_CV/REFIT requires a target-bound training data identity"
471 .to_string(),
472 ));
473 }
474 None => {}
475 }
476 self.fit_view.validate()?;
477 if let Some(view) = &self.prediction_view {
478 view.validate()?;
479 }
480 Ok(())
481 }
482}
483
484#[derive(Clone, Debug, PartialEq)]
486pub struct MethodsPlsData {
487 pub fit: MethodsPlsDataset,
488 pub prediction: Option<MethodsPlsDataset>,
489}
490
491impl MethodsPlsData {
492 pub fn validate_for(&self, request: &MethodsPlsDataRequest) -> Result<()> {
493 request.validate()?;
494 self.fit.validate("fit", request.phase != Phase::Predict)?;
495 if let Some(expected_sample_ids) = &request.fit_view.sample_ids {
496 if self.fit.sample_ids != *expected_sample_ids {
497 return Err(DagMlError::RuntimeValidation(
498 "portable Methods PLS fit rows do not exactly match the scheduler-selected identity view".to_string(),
499 ));
500 }
501 } else if request.phase != Phase::Predict {
502 return Err(DagMlError::RuntimeValidation(
503 "portable Methods PLS fit view must carry scheduler-selected sample identities"
504 .to_string(),
505 ));
506 }
507 if let Some(prediction) = &self.prediction {
508 prediction.validate("prediction", request.phase == Phase::FitCv)?;
509 if prediction.sample_ids != request.prediction_view_sample_ids()? {
510 return Err(DagMlError::RuntimeValidation(
511 "portable Methods PLS prediction rows do not exactly match the scheduler-selected identity view".to_string(),
512 ));
513 }
514 if prediction.x.cols != self.fit.x.cols
515 || prediction.target_names != self.fit.target_names
516 || matches!((&prediction.y, &self.fit.y), (Some(left), Some(right)) if left.cols != right.cols)
517 {
518 return Err(DagMlError::RuntimeValidation(
519 "portable Methods PLS prediction schema differs from fit schema".to_string(),
520 ));
521 }
522 }
523 if request.prediction_view.is_some() != self.prediction.is_some() {
524 return Err(DagMlError::RuntimeValidation(
525 "portable Methods PLS provider did not return exactly the requested prediction view".to_string(),
526 ));
527 }
528 Ok(())
529 }
530}
531
532pub const METHODS_PLS_PREDICT_CONTENT_PROFILE: &str = "n4a-matrix-f64-le.v1";
541
542pub fn methods_pls_predict_feature_content_fingerprint(
549 matrix: &MethodsPlsMatrix,
550) -> Result<String> {
551 matrix.validate("PREDICT feature fingerprint")?;
552 let rows = u64::try_from(matrix.rows).map_err(|_| {
553 DagMlError::RuntimeValidation(
554 "portable Methods PLS PREDICT matrix row count does not fit the content identity profile"
555 .to_string(),
556 )
557 })?;
558 let cols = u64::try_from(matrix.cols).map_err(|_| {
559 DagMlError::RuntimeValidation(
560 "portable Methods PLS PREDICT matrix column count does not fit the content identity profile"
561 .to_string(),
562 )
563 })?;
564 let mut hasher = Sha256::new();
565 hasher.update(METHODS_PLS_PREDICT_CONTENT_PROFILE.as_bytes());
566 hasher.update([0]);
567 hasher.update(rows.to_le_bytes());
568 hasher.update(cols.to_le_bytes());
569 for value in &matrix.values {
570 hasher.update(value.to_bits().to_le_bytes());
571 }
572 Ok(format!("{:x}", hasher.finalize()))
573}
574
575#[derive(Clone, Debug, PartialEq)]
576pub struct MethodsPlsPredictInput {
577 pub data_content_profile: String,
579 pub data_content_fingerprint: String,
580 pub dataset: MethodsPlsDataset,
581}
582
583#[derive(Debug)]
591pub struct MethodsPlsPredictDataProvider {
592 inner: EnvelopeAttestedRuntimeDataProvider<crate::data::InMemoryDataProvider>,
593 inputs: BTreeMap<String, MethodsPlsPredictInput>,
594}
595
596impl MethodsPlsPredictDataProvider {
597 pub fn new<I>(
598 owner_controller: ControllerId,
599 bindings: I,
600 envelopes: BTreeMap<String, ExternalDataPlanEnvelope>,
601 inputs: BTreeMap<String, MethodsPlsPredictInput>,
602 ) -> Result<Self>
603 where
604 I: IntoIterator<Item = DataBinding>,
605 {
606 let bindings = bindings.into_iter().collect::<Vec<_>>();
607 let expected_keys = bindings
608 .iter()
609 .map(|binding| data_binding_requirement_key(&binding.node_id, &binding.input_name))
610 .collect::<BTreeSet<_>>();
611 let input_keys = inputs.keys().cloned().collect::<BTreeSet<_>>();
612 if input_keys.is_empty() || !input_keys.is_subset(&expected_keys) {
613 return Err(DagMlError::RuntimeValidation(format!(
614 "portable Methods PLS PREDICT inputs must name registered runtime bindings (unexpected: [{}])",
615 input_keys
616 .difference(&expected_keys)
617 .cloned()
618 .collect::<Vec<_>>()
619 .join(", "),
620 )));
621 }
622 for (key, input) in &inputs {
623 input.dataset.validate("predict input", false)?;
624 if input.data_content_profile != METHODS_PLS_PREDICT_CONTENT_PROFILE {
625 return Err(DagMlError::RuntimeValidation(format!(
626 "portable Methods PLS PREDICT input `{key}` has unsupported feature content profile `{}`",
627 input.data_content_profile,
628 )));
629 }
630 let actual_fingerprint =
631 methods_pls_predict_feature_content_fingerprint(&input.dataset.x)?;
632 if input.data_content_fingerprint != actual_fingerprint {
633 return Err(DagMlError::RuntimeValidation(format!(
634 "portable Methods PLS PREDICT input `{key}` feature content fingerprint does not match its row-major f64 values"
635 )));
636 }
637 if input.dataset.y.is_some() {
638 return Err(DagMlError::RuntimeValidation(format!(
639 "portable Methods PLS PREDICT input `{key}` must not carry targets"
640 )));
641 }
642 let envelope = envelopes.get(key).ok_or_else(|| {
643 DagMlError::RuntimeValidation(format!(
644 "portable Methods PLS PREDICT input `{key}` has no external envelope"
645 ))
646 })?;
647 envelope.validate()?;
648 let expected_fingerprint = envelope.data_content_fingerprint.as_deref().ok_or_else(|| {
649 DagMlError::RuntimeValidation(format!(
650 "portable Methods PLS PREDICT envelope `{key}` has no feature content fingerprint"
651 ))
652 })?;
653 if input.data_content_fingerprint != expected_fingerprint {
654 return Err(DagMlError::RuntimeValidation(format!(
655 "portable Methods PLS PREDICT input `{key}` feature content fingerprint does not match its envelope"
656 )));
657 }
658 if envelope.target_content_fingerprint.is_some() {
659 return Err(DagMlError::RuntimeValidation(format!(
660 "portable Methods PLS PREDICT input `{key}` requires a target-free envelope"
661 )));
662 }
663 }
664
665 let mut raw = crate::data::InMemoryDataProvider::new(owner_controller);
666 for envelope in envelopes.values().cloned() {
667 raw.register_envelope(envelope)?;
668 }
669 let inner = EnvelopeAttestedRuntimeDataProvider::new(raw, bindings, envelopes)?;
670 Ok(Self { inner, inputs })
671 }
672
673 fn input_for(&self, request: &MethodsPlsDataRequest) -> Result<&MethodsPlsPredictInput> {
674 request.validate()?;
675 if request.phase != Phase::Predict || request.identity.is_some() {
676 return Err(DagMlError::RuntimeValidation(
677 "portable Methods PLS target-free provider supports only PREDICT without a training identity"
678 .to_string(),
679 ));
680 }
681 if request.prediction_view.is_some() {
682 return Err(DagMlError::RuntimeValidation(
683 "portable Methods PLS target-free provider does not support FIT_CV validation views"
684 .to_string(),
685 ));
686 }
687 let key =
688 data_binding_requirement_key(&request.binding.node_id, &request.binding.input_name);
689 let input = self.inputs.get(&key).ok_or_else(|| {
690 DagMlError::RuntimeValidation(format!(
691 "portable Methods PLS target-free provider has no input for `{key}`"
692 ))
693 })?;
694 if let Some(expected_sample_ids) = &request.fit_view.sample_ids {
695 if input.dataset.sample_ids != *expected_sample_ids {
696 return Err(DagMlError::RuntimeValidation(
697 "portable Methods PLS target-free input rows do not match the scheduler-selected identity view"
698 .to_string(),
699 ));
700 }
701 }
702 Ok(input)
703 }
704}
705
706impl RuntimeDataProvider for MethodsPlsPredictDataProvider {
707 fn materialize(&self, request: &DataMaterializationRequest) -> Result<HandleRef> {
708 self.inner.materialize(request)
709 }
710
711 fn make_view(&self, request: &DataViewRequest) -> Result<HandleRef> {
712 self.inner.make_view(request)
713 }
714
715 fn training_data_identity(
716 &self,
717 binding: &DataBinding,
718 ) -> Result<Option<crate::training::TrainingDataIdentity>> {
719 self.inner.training_data_identity(binding)
720 }
721
722 fn coordinator_relations(&self, binding: &DataBinding) -> Result<Option<SampleRelationSet>> {
723 self.inner.coordinator_relations(binding)
724 }
725
726 fn methods_pls_capability(&self) -> Result<()> {
727 Ok(())
728 }
729
730 fn preflight_methods_pls(&self, request: &MethodsPlsDataRequest) -> Result<()> {
731 self.input_for(request)?;
732 Ok(())
733 }
734
735 fn methods_pls_data(&self, request: &MethodsPlsDataRequest) -> Result<MethodsPlsData> {
736 let input = self.input_for(request)?;
737 Ok(MethodsPlsData {
738 fit: input.dataset.clone(),
739 prediction: None,
740 })
741 }
742}
743
744impl MethodsPlsDataRequest {
745 fn prediction_view_sample_ids(&self) -> Result<Vec<SampleId>> {
746 self.prediction_view
747 .as_ref()
748 .and_then(|view| view.sample_ids.clone())
749 .ok_or_else(|| {
750 DagMlError::RuntimeValidation(
751 "portable Methods PLS prediction view must carry scheduler-selected sample identities".to_string(),
752 )
753 })
754 }
755}
756
757#[derive(Debug)]
758struct EnvelopeAttestation {
759 binding: DataBinding,
760 envelope: ExternalDataPlanEnvelope,
761 identity: Option<crate::training::TrainingDataIdentity>,
765}
766
767#[derive(Debug)]
776pub struct EnvelopeAttestedRuntimeDataProvider<P> {
777 inner: P,
778 attestations: BTreeMap<String, EnvelopeAttestation>,
779}
780
781impl<P> EnvelopeAttestedRuntimeDataProvider<P> {
782 pub fn new<I>(
783 inner: P,
784 bindings: I,
785 mut envelopes: BTreeMap<String, ExternalDataPlanEnvelope>,
786 ) -> Result<Self>
787 where
788 I: IntoIterator<Item = DataBinding>,
789 {
790 let mut bindings_by_key: BTreeMap<String, DataBinding> = BTreeMap::new();
791 for binding in bindings {
792 binding.validate()?;
793 let key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
794 if let Some(previous) = bindings_by_key.get(&key) {
795 let detail = if previous.node_id == binding.node_id
796 && previous.input_name == binding.input_name
797 {
798 "duplicates the same coordinates"
799 } else {
800 "uses distinct coordinates that collide under the V1 node.input spelling"
801 };
802 return Err(DagMlError::RuntimeValidation(format!(
803 "data binding requirement key `{key}` {detail}"
804 )));
805 }
806 bindings_by_key.insert(key, binding);
807 }
808
809 let expected_keys = bindings_by_key.keys().cloned().collect::<BTreeSet<_>>();
810 let actual_keys = envelopes.keys().cloned().collect::<BTreeSet<_>>();
811 if expected_keys != actual_keys {
812 let missing = expected_keys
813 .difference(&actual_keys)
814 .cloned()
815 .collect::<Vec<_>>();
816 let unexpected = actual_keys
817 .difference(&expected_keys)
818 .cloned()
819 .collect::<Vec<_>>();
820 return Err(DagMlError::RuntimeValidation(format!(
821 "attested data envelopes must exactly cover runtime bindings (missing: [{}]; unexpected: [{}])",
822 missing.join(", "),
823 unexpected.join(", ")
824 )));
825 }
826
827 let mut attestations = BTreeMap::new();
828 for (key, binding) in bindings_by_key {
829 let envelope = envelopes
830 .remove(&key)
831 .expect("exact key coverage was checked above");
832 let identity = if envelope.relation_fingerprint.is_some()
833 && envelope.data_content_fingerprint.is_some()
834 && envelope.target_content_fingerprint.is_some()
835 {
836 Some(
837 crate::training::TrainingDataIdentity::from_binding_envelope(
838 &binding, &envelope,
839 )?,
840 )
841 } else {
842 None
843 };
844 attestations.insert(
845 key,
846 EnvelopeAttestation {
847 binding,
848 envelope,
849 identity,
850 },
851 );
852 }
853
854 Ok(Self {
855 inner,
856 attestations,
857 })
858 }
859
860 pub fn inner(&self) -> &P {
861 &self.inner
862 }
863
864 pub fn into_inner(self) -> P {
865 self.inner
866 }
867
868 fn attestation_for_binding(&self, binding: &DataBinding) -> Result<&EnvelopeAttestation> {
869 binding.validate()?;
870 let key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
871 let attestation = self.attestations.get(&key).ok_or_else(|| {
872 DagMlError::RuntimeValidation(format!(
873 "runtime data binding `{key}` has no registered envelope attestation"
874 ))
875 })?;
876 if attestation.binding != *binding {
877 return Err(DagMlError::RuntimeValidation(format!(
878 "runtime data binding `{key}` does not exactly match its attested binding"
879 )));
880 }
881 Ok(attestation)
882 }
883
884 fn validate_request_binding(
885 &self,
886 node_id: &NodeId,
887 input_name: &str,
888 binding: &DataBinding,
889 ) -> Result<()> {
890 if node_id != &binding.node_id || input_name != binding.input_name {
891 return Err(DagMlError::RuntimeValidation(format!(
892 "runtime data request coordinates `{node_id}.{input_name}` do not match binding `{}`",
893 data_binding_requirement_key(&binding.node_id, &binding.input_name)
894 )));
895 }
896 self.attestation_for_binding(binding)?;
897 Ok(())
898 }
899}
900
901impl<P: RuntimeDataProvider> RuntimeDataProvider for EnvelopeAttestedRuntimeDataProvider<P> {
902 fn materialize(&self, request: &DataMaterializationRequest) -> Result<HandleRef> {
903 self.validate_request_binding(&request.node_id, &request.input_name, &request.binding)?;
904 self.inner.materialize(request)
905 }
906
907 fn make_view(&self, request: &DataViewRequest) -> Result<HandleRef> {
908 request.view.validate()?;
909 self.validate_request_binding(&request.node_id, &request.input_name, &request.binding)?;
910 self.inner.make_view(request)
911 }
912
913 fn training_data_identity(
914 &self,
915 binding: &DataBinding,
916 ) -> Result<Option<crate::training::TrainingDataIdentity>> {
917 Ok(self.attestation_for_binding(binding)?.identity.clone())
918 }
919
920 fn coordinator_relations(&self, binding: &DataBinding) -> Result<Option<SampleRelationSet>> {
921 Ok(self
922 .attestation_for_binding(binding)?
923 .envelope
924 .coordinator_relations
925 .clone())
926 }
927
928 fn methods_pls_capability(&self) -> Result<()> {
929 self.inner.methods_pls_capability()
930 }
931
932 fn preflight_methods_pls(&self, request: &MethodsPlsDataRequest) -> Result<()> {
933 request.validate()?;
934 self.inner.preflight_methods_pls(request)
935 }
936
937 fn methods_pls_data(&self, request: &MethodsPlsDataRequest) -> Result<MethodsPlsData> {
938 request.validate()?;
939 self.inner.methods_pls_data(request)
940 }
941}
942
943pub trait RuntimeController: Send + Sync {
944 fn controller_id(&self) -> &ControllerId;
945 fn invoke(&self, task: &NodeTask) -> Result<NodeResult>;
946
947 fn export_artifact_payload(&self, _artifact_id: &ArtifactId) -> Result<Option<Vec<u8>>> {
951 Ok(None)
952 }
953
954 fn hydrate_artifact_payload(
960 &self,
961 _request: &ArtifactMaterializationRequest,
962 _payload: &[u8],
963 ) -> Result<HandleRef> {
964 Err(DagMlError::RuntimeValidation(format!(
965 "runtime controller `{}` cannot hydrate a raw portable artifact payload",
966 self.controller_id()
967 )))
968 }
969
970 fn release_hydrated_artifact_payload(&self, _handle: &HandleRef) -> Result<()> {
975 Err(DagMlError::RuntimeValidation(format!(
976 "runtime controller `{}` cannot release a hydrated raw portable artifact payload",
977 self.controller_id()
978 )))
979 }
980
981 fn invoke_with_data_provider(
985 &self,
986 task: &NodeTask,
987 _data_provider: &dyn RuntimeDataProvider,
988 ) -> Result<NodeResult> {
989 self.invoke(task)
990 }
991
992 fn create_tuner_session(
999 &self,
1000 task: &RuntimeHpoCampaignTask,
1001 _context: &RuntimeHpoExecutionContext,
1002 ) -> Result<Box<dyn RuntimeTunerSession>> {
1003 Err(DagMlError::RuntimeValidation(format!(
1004 "runtime controller `{}` does not implement an execution-local tuner session for HPO campaign `{}`",
1005 self.controller_id(), task.operation_id
1006 )))
1007 }
1008
1009 fn invoke_aggregation(
1010 &self,
1011 task: &AggregationControllerTask,
1012 ) -> Result<AggregationControllerResult> {
1013 Err(DagMlError::RuntimeValidation(format!(
1014 "runtime controller `{}` does not implement aggregation task `{}`",
1015 self.controller_id(),
1016 task.task_id
1017 )))
1018 }
1019}
1020
1021pub trait RuntimeTunerSession {
1028 fn trial_history_len(&self) -> Result<u32>;
1032
1033 fn ask(&mut self) -> Result<Option<RuntimeHpoProposal>>;
1034
1035 fn report_intermediate(
1036 &mut self,
1037 intermediate: RuntimeHpoIntermediate,
1038 ) -> Result<RuntimeHpoIntermediateOutcome>;
1039
1040 fn tell(&mut self, trial_id: i64, terminal: RuntimeHpoTerminal) -> Result<()>;
1041
1042 fn incumbent(&self, variants: &BTreeMap<i64, VariantId>)
1046 -> Result<Option<RuntimeHpoIncumbent>>;
1047
1048 fn terminal_trial_snapshots(
1052 &self,
1053 variants: &BTreeMap<i64, VariantId>,
1054 ) -> Result<Vec<RuntimeHpoTerminalSnapshot>>;
1055
1056 fn checkpoint(&self) -> Result<crate::hpo::N4moptCheckpointArtifact>;
1060}
1061pub(crate) struct CollectedInputs {
1062 pub(crate) handles: BTreeMap<String, HandleRef>,
1063 pub(crate) data_views: BTreeMap<String, DataProviderViewSpec>,
1064 pub(crate) prediction_inputs: BTreeMap<String, PredictionInputSpec>,
1065 pub(crate) skip_node: bool,
1066}
1067
1068pub(crate) fn data_view_key(input_name: &str) -> String {
1069 format!("data:{input_name}")
1070}
1071
1072pub(crate) fn validation_data_view_key(input_name: &str) -> String {
1073 format!("{input_name}:validation")
1074}
1075
1076pub(crate) fn derive_output_data_views(
1077 plan: &ExecutionPlan,
1078 task: &NodeTask,
1079 result: &NodeResult,
1080) -> Result<BTreeMap<String, DataProviderViewSpec>> {
1081 let node = plan
1082 .graph_plan
1083 .graph
1084 .nodes
1085 .iter()
1086 .find(|node| node.id == task.node_plan.node_id)
1087 .expect("execution plan was validated");
1088 let mut views = BTreeMap::new();
1089 for port in node
1090 .ports
1091 .outputs
1092 .iter()
1093 .filter(|port| port.kind == PortKind::Data)
1094 {
1095 let Some(handle) = result.outputs.get(&port.name) else {
1096 continue;
1097 };
1098 if !matches!(handle.kind, HandleKind::Data | HandleKind::DataView) {
1099 return Err(DagMlError::RuntimeValidation(format!(
1100 "node `{}` emitted data output `{}` with non-data/data-view handle kind {:?}",
1101 task.node_plan.node_id, port.name, handle.kind
1102 )));
1103 }
1104 if let Some(view) = primary_output_data_view(task) {
1105 views.insert(
1106 port.name.clone(),
1107 output_data_view_for_port(task, result, &port.name, view)?,
1108 );
1109 }
1110 if let Some(validation_view) = validation_output_data_view(task) {
1111 views.insert(
1112 validation_data_view_key(&port.name),
1113 output_data_view_for_port(task, result, &port.name, validation_view)?,
1114 );
1115 }
1116 }
1117 Ok(views)
1118}
1119
1120pub(crate) fn output_data_view_for_port(
1121 task: &NodeTask,
1122 result: &NodeResult,
1123 port_name: &str,
1124 base_view: &DataProviderViewSpec,
1125) -> Result<DataProviderViewSpec> {
1126 let mut view = base_view.clone();
1127 if let Some(upstream_provenance) = view.extra.remove(DATA_OUTPUT_PROVENANCE_KEY) {
1128 let provenance: DataOutputProvenance =
1129 serde_json::from_value(upstream_provenance).map_err(|error| {
1130 DagMlError::RuntimeValidation(format!(
1131 "node `{}` cannot propagate data output `{port_name}` because upstream data output provenance is invalid JSON: {error}",
1132 task.node_plan.node_id
1133 ))
1134 })?;
1135 provenance.validate().map_err(|error| {
1136 DagMlError::RuntimeValidation(format!(
1137 "node `{}` cannot propagate data output `{port_name}` because upstream data output provenance is invalid: {error}",
1138 task.node_plan.node_id
1139 ))
1140 })?;
1141 }
1142 let shape_deltas = result
1143 .shape_deltas
1144 .iter()
1145 .filter(|delta| delta.node_id == task.node_plan.node_id)
1146 .cloned()
1147 .collect::<Vec<_>>();
1148 let mut provenance = DataOutputProvenance {
1149 schema_version: DATA_OUTPUT_PROVENANCE_SCHEMA_VERSION,
1150 producer_node: task.node_plan.node_id.clone(),
1151 producer_port: port_name.to_string(),
1152 producer_phase: task.phase,
1153 variant_id: task.variant_id.clone(),
1154 fold_id: task.fold_id.clone(),
1155 shape_plan_fingerprint: None,
1156 aggregation_policy_fingerprint: None,
1157 feature_namespace: None,
1158 feature_schema_fingerprint: None,
1159 representation_plan: None,
1160 representation_replay_manifest: None,
1161 representation_compatibility: None,
1162 relation_delta_fingerprint: None,
1163 shape_deltas,
1164 };
1165 if let Some(shape_plan) = &task.node_plan.shape_plan {
1166 provenance.shape_plan_fingerprint = Some(stable_json_fingerprint(shape_plan)?);
1167 provenance.aggregation_policy_fingerprint =
1168 Some(stable_json_fingerprint(&shape_plan.aggregation_policy)?);
1169 provenance.feature_namespace = shape_plan.feature_namespace.clone();
1170 provenance.feature_schema_fingerprint =
1171 output_feature_schema_fingerprint(shape_plan, result);
1172 }
1173 provenance.validate()?;
1174
1175 view.extra.insert(
1176 DATA_OUTPUT_PROVENANCE_KEY.to_string(),
1177 serde_json::to_value(provenance)?,
1178 );
1179 view.validate()?;
1180 Ok(view)
1181}
1182
1183pub(crate) fn output_feature_schema_fingerprint(
1184 shape_plan: &crate::policy::DataModelShapePlan,
1185 result: &NodeResult,
1186) -> Option<String> {
1187 result
1188 .shape_deltas
1189 .iter()
1190 .rev()
1191 .find(|delta| delta.kind == ShapeDeltaKind::Feature)
1192 .map(|delta| delta.after_fingerprint.clone())
1193 .or_else(|| shape_plan.feature_schema_fingerprint.clone())
1194}
1195
1196pub(crate) fn primary_output_data_view(task: &NodeTask) -> Option<&DataProviderViewSpec> {
1197 task.data_views
1198 .values()
1199 .find(|view| view.partition != DataRequestPartition::FoldValidation)
1200 .or_else(|| task.data_views.values().next())
1201}
1202
1203pub(crate) fn validation_output_data_view(task: &NodeTask) -> Option<&DataProviderViewSpec> {
1204 task.data_views
1205 .values()
1206 .find(|view| view.partition == DataRequestPartition::FoldValidation)
1207}
1208
1209pub(crate) fn make_data_view_handle(
1210 data_provider: &dyn RuntimeDataProvider,
1211 ctx: &RunContext,
1212 node_plan: &NodePlan,
1213 scope: &PhaseScope,
1214 binding: &DataBinding,
1215 data_handle: &HandleRef,
1216 view: &DataProviderViewSpec,
1217) -> Result<HandleRef> {
1218 view.validate()?;
1219 let view_handle = data_provider.make_view(&DataViewRequest {
1220 run_id: ctx.run_id.clone(),
1221 node_id: node_plan.node_id.clone(),
1222 input_name: binding.input_name.clone(),
1223 phase: scope.phase,
1224 variant_id: scope.variant_id.clone(),
1225 fold_id: scope.fold_id.clone(),
1226 binding: binding.clone(),
1227 data_handle: data_handle.clone(),
1228 view: view.clone(),
1229 })?;
1230 if !matches!(view_handle.kind, HandleKind::Data | HandleKind::DataView) {
1234 return Err(DagMlError::RuntimeValidation(format!(
1235 "node `{}` data view `{}` resolved to a non-data/data-view handle kind {:?}",
1236 node_plan.node_id, binding.input_name, view_handle.kind
1237 )));
1238 }
1239 Ok(view_handle)
1240}
1241
1242pub(crate) fn data_view_for_scope(
1243 binding: &DataBinding,
1244 fold_set: Option<&FoldSet>,
1245 scope: &PhaseScope,
1246 branch_view: Option<&crate::data::BranchViewPlan>,
1247 excluded_samples: &BTreeSet<SampleId>,
1248) -> Result<DataProviderViewSpec> {
1249 let partition = data_partition_for_scope(binding, scope);
1250 let role = match scope.phase {
1253 Phase::FitCv | Phase::Refit => DataViewRole::Fit,
1254 _ => DataViewRole::NonFit,
1255 };
1256 data_view_for_partition(
1257 binding,
1258 fold_set,
1259 scope,
1260 partition,
1261 branch_view,
1262 role,
1263 excluded_samples,
1264 )
1265}
1266
1267pub(crate) fn validation_data_view_for_scope(
1268 binding: &DataBinding,
1269 fold_set: Option<&FoldSet>,
1270 scope: &PhaseScope,
1271 branch_view: Option<&crate::data::BranchViewPlan>,
1272 excluded_samples: &BTreeSet<SampleId>,
1273) -> Result<Option<DataProviderViewSpec>> {
1274 if scope.phase != Phase::FitCv || scope.fold_id.is_none() {
1275 return Ok(None);
1276 }
1277 let partition = binding.view_policy.predict_partition;
1278 if partition == data_partition_for_scope(binding, scope) {
1279 return Ok(None);
1280 }
1281 data_view_for_partition(
1283 binding,
1284 fold_set,
1285 scope,
1286 partition,
1287 branch_view,
1288 DataViewRole::NonFit,
1289 excluded_samples,
1290 )
1291 .map(Some)
1292}
1293
1294#[cfg(test)]
1295mod envelope_attested_provider_tests {
1296 use std::cell::Cell;
1297
1298 use super::*;
1299
1300 #[derive(Debug, Default)]
1301 struct ProbeProvider {
1302 materialize_calls: Cell<usize>,
1303 make_view_calls: Cell<usize>,
1304 }
1305
1306 impl RuntimeDataProvider for ProbeProvider {
1307 fn materialize(&self, _request: &DataMaterializationRequest) -> Result<HandleRef> {
1308 self.materialize_calls.set(self.materialize_calls.get() + 1);
1309 Ok(HandleRef {
1310 handle: 41,
1311 kind: HandleKind::Data,
1312 owner_controller: ControllerId::new("controller:data.probe").unwrap(),
1313 })
1314 }
1315
1316 fn make_view(&self, _request: &DataViewRequest) -> Result<HandleRef> {
1317 self.make_view_calls.set(self.make_view_calls.get() + 1);
1318 Ok(HandleRef {
1319 handle: 42,
1320 kind: HandleKind::DataView,
1321 owner_controller: ControllerId::new("controller:data.probe").unwrap(),
1322 })
1323 }
1324 }
1325
1326 fn complete_envelope() -> ExternalDataPlanEnvelope {
1327 let mut envelope: ExternalDataPlanEnvelope = serde_json::from_str(include_str!(
1328 "../../tests/fixtures/package/data/coordinator_data_plan_envelope_sample12.json"
1329 ))
1330 .unwrap();
1331 envelope.data_content_fingerprint = Some("a".repeat(64));
1332 envelope.target_content_fingerprint = Some("b".repeat(64));
1333 envelope
1334 }
1335
1336 fn binding_for(
1337 node_id: &str,
1338 input_name: &str,
1339 envelope: &ExternalDataPlanEnvelope,
1340 ) -> DataBinding {
1341 DataBinding {
1342 node_id: NodeId::new(node_id).unwrap(),
1343 input_name: input_name.to_string(),
1344 request_id: "request:data.probe".to_string(),
1345 schema_fingerprint: envelope.schema_fingerprint.clone(),
1346 plan_fingerprint: envelope.plan_fingerprint.clone(),
1347 relation_fingerprint: envelope.relation_fingerprint.clone(),
1348 output_representation: "tabular_numeric".to_string(),
1349 feature_set_id: Some(input_name.to_string()),
1350 source_ids: vec!["source:probe".to_string()],
1351 require_relations: true,
1352 view_policy: Default::default(),
1353 metadata: BTreeMap::new(),
1354 }
1355 }
1356
1357 fn envelopes_for(
1358 binding: &DataBinding,
1359 envelope: ExternalDataPlanEnvelope,
1360 ) -> BTreeMap<String, ExternalDataPlanEnvelope> {
1361 BTreeMap::from([(
1362 data_binding_requirement_key(&binding.node_id, &binding.input_name),
1363 envelope,
1364 )])
1365 }
1366
1367 fn materialization_request(binding: &DataBinding) -> DataMaterializationRequest {
1368 DataMaterializationRequest {
1369 run_id: RunId::new("run:attested.provider").unwrap(),
1370 node_id: binding.node_id.clone(),
1371 input_name: binding.input_name.clone(),
1372 phase: Phase::Refit,
1373 variant_id: None,
1374 fold_id: None,
1375 binding: binding.clone(),
1376 }
1377 }
1378
1379 #[test]
1380 fn envelope_attested_provider_delegates_and_returns_exact_attestations() {
1381 let envelope = complete_envelope();
1382 let binding = binding_for("model:base", "x", &envelope);
1383 let expected_identity =
1384 crate::training::TrainingDataIdentity::from_binding_envelope(&binding, &envelope)
1385 .unwrap();
1386 let expected_relations = envelope.coordinator_relations.clone();
1387 let provider = EnvelopeAttestedRuntimeDataProvider::new(
1388 ProbeProvider::default(),
1389 vec![binding.clone()],
1390 envelopes_for(&binding, envelope),
1391 )
1392 .unwrap();
1393
1394 assert_eq!(
1395 provider.training_data_identity(&binding).unwrap(),
1396 Some(expected_identity)
1397 );
1398 assert_eq!(
1399 provider.coordinator_relations(&binding).unwrap(),
1400 expected_relations
1401 );
1402
1403 let materialization = materialization_request(&binding);
1404 let data_handle = provider.materialize(&materialization).unwrap();
1405 assert_eq!(data_handle.handle, 41);
1406 let view_handle = provider
1407 .make_view(&DataViewRequest {
1408 run_id: materialization.run_id,
1409 node_id: binding.node_id.clone(),
1410 input_name: binding.input_name.clone(),
1411 phase: Phase::Refit,
1412 variant_id: None,
1413 fold_id: None,
1414 binding: binding.clone(),
1415 data_handle,
1416 view: DataProviderViewSpec {
1417 sample_ids: None,
1418 partition: DataRequestPartition::FullTrain,
1419 fold_id: None,
1420 source_ids: None,
1421 columns: None,
1422 include_augmented: true,
1423 include_excluded: false,
1424 branch_view: None,
1425 extra: BTreeMap::new(),
1426 },
1427 })
1428 .unwrap();
1429 assert_eq!(view_handle.handle, 42);
1430 assert_eq!(provider.inner().materialize_calls.get(), 1);
1431 assert_eq!(provider.inner().make_view_calls.get(), 1);
1432
1433 let inner = provider.into_inner();
1434 assert_eq!(inner.materialize_calls.get(), 1);
1435 assert_eq!(inner.make_view_calls.get(), 1);
1436 }
1437
1438 #[test]
1439 fn envelope_attested_provider_preserves_target_free_predict_envelopes() {
1440 let mut envelope = complete_envelope();
1441 envelope.target_content_fingerprint = None;
1442 let binding = binding_for("model:base", "x", &envelope);
1443 let provider = EnvelopeAttestedRuntimeDataProvider::new(
1444 ProbeProvider::default(),
1445 vec![binding.clone()],
1446 envelopes_for(&binding, envelope.clone()),
1447 )
1448 .unwrap();
1449
1450 assert_eq!(provider.training_data_identity(&binding).unwrap(), None);
1454 assert_eq!(
1455 provider.coordinator_relations(&binding).unwrap(),
1456 envelope.coordinator_relations
1457 );
1458 let mut request = materialization_request(&binding);
1459 request.phase = Phase::Predict;
1460 assert_eq!(provider.materialize(&request).unwrap().handle, 41);
1461 }
1462
1463 #[test]
1464 fn envelope_attested_provider_requires_exact_envelope_coverage() {
1465 let envelope = complete_envelope();
1466 let binding = binding_for("model:base", "x", &envelope);
1467
1468 let missing = EnvelopeAttestedRuntimeDataProvider::new(
1469 ProbeProvider::default(),
1470 vec![binding.clone()],
1471 BTreeMap::new(),
1472 )
1473 .unwrap_err();
1474 assert!(missing.to_string().contains("exactly cover"));
1475 assert!(missing.to_string().contains("model:base.x"));
1476
1477 let mut unexpected = envelopes_for(&binding, envelope.clone());
1478 unexpected.insert("model:other.x".to_string(), envelope);
1479 let extra = EnvelopeAttestedRuntimeDataProvider::new(
1480 ProbeProvider::default(),
1481 vec![binding],
1482 unexpected,
1483 )
1484 .unwrap_err();
1485 assert!(extra.to_string().contains("exactly cover"));
1486 assert!(extra.to_string().contains("model:other.x"));
1487 }
1488
1489 #[test]
1490 fn envelope_attested_provider_rejects_rendered_key_collisions() {
1491 let envelope = complete_envelope();
1492 let left = binding_for("a.b", "c", &envelope);
1493 let right = binding_for("a", "b.c", &envelope);
1494 assert_eq!(
1495 data_binding_requirement_key(&left.node_id, &left.input_name),
1496 data_binding_requirement_key(&right.node_id, &right.input_name)
1497 );
1498
1499 let error = EnvelopeAttestedRuntimeDataProvider::new(
1500 ProbeProvider::default(),
1501 vec![left.clone(), right],
1502 envelopes_for(&left, envelope),
1503 )
1504 .unwrap_err();
1505 assert!(error.to_string().contains("distinct coordinates"));
1506 assert!(error.to_string().contains("a.b.c"));
1507 }
1508
1509 #[test]
1510 fn envelope_attested_provider_refuses_unattested_binding_before_delegation() {
1511 let envelope = complete_envelope();
1512 let binding = binding_for("model:base", "x", &envelope);
1513 let provider = EnvelopeAttestedRuntimeDataProvider::new(
1514 ProbeProvider::default(),
1515 vec![binding.clone()],
1516 envelopes_for(&binding, envelope),
1517 )
1518 .unwrap();
1519 let mut changed = binding;
1520 changed.request_id = "request:data.changed".to_string();
1521
1522 let error = provider
1523 .materialize(&materialization_request(&changed))
1524 .unwrap_err();
1525 assert!(error.to_string().contains("does not exactly match"));
1526 assert_eq!(provider.inner().materialize_calls.get(), 0);
1527 }
1528
1529 #[test]
1530 fn envelope_attested_provider_marks_incomplete_envelope_as_non_training() {
1531 let mut envelope = complete_envelope();
1532 envelope.data_content_fingerprint = None;
1533 let binding = binding_for("model:base", "x", &envelope);
1534 let provider = EnvelopeAttestedRuntimeDataProvider::new(
1535 ProbeProvider::default(),
1536 vec![binding.clone()],
1537 envelopes_for(&binding, envelope),
1538 )
1539 .unwrap();
1540 assert_eq!(provider.training_data_identity(&binding).unwrap(), None);
1541 }
1542
1543 #[test]
1544 fn methods_pls_request_allows_target_free_predict_but_not_training() {
1545 let envelope = complete_envelope();
1546 let binding = binding_for("model:base", "x", &envelope);
1547 let predict_view = DataProviderViewSpec {
1548 sample_ids: Some(vec![SampleId::new("sample:1").unwrap()]),
1549 partition: DataRequestPartition::Predict,
1550 fold_id: None,
1551 source_ids: None,
1552 columns: None,
1553 include_augmented: false,
1554 include_excluded: false,
1555 branch_view: None,
1556 extra: BTreeMap::new(),
1557 };
1558 let request = MethodsPlsDataRequest {
1559 node_id: binding.node_id.clone(),
1560 phase: Phase::Predict,
1561 variant_id: None,
1562 fold_id: None,
1563 binding: binding.clone(),
1564 identity: None,
1565 fit_view: predict_view.clone(),
1566 prediction_view: None,
1567 };
1568 request.validate().unwrap();
1569
1570 let mut refit = request;
1571 refit.phase = Phase::Refit;
1572 refit.fit_view.partition = DataRequestPartition::FullTrain;
1573 let error = refit.validate().unwrap_err();
1574 assert!(error
1575 .to_string()
1576 .contains("FIT_CV/REFIT requires a target-bound training data identity"));
1577 }
1578
1579 #[test]
1580 fn methods_pls_predict_provider_binds_x_only_rows_to_the_envelope() {
1581 let mut envelope = complete_envelope();
1582 envelope.target_content_fingerprint = None;
1583 let binding = binding_for("model:base", "x", &envelope);
1584 let key = data_binding_requirement_key(&binding.node_id, &binding.input_name);
1585 let input = MethodsPlsPredictInput {
1586 data_content_profile: METHODS_PLS_PREDICT_CONTENT_PROFILE.to_string(),
1587 data_content_fingerprint: methods_pls_predict_feature_content_fingerprint(
1588 &MethodsPlsMatrix {
1589 values: vec![1.0, 2.0],
1590 rows: 1,
1591 cols: 2,
1592 },
1593 )
1594 .unwrap(),
1595 dataset: MethodsPlsDataset {
1596 sample_ids: vec![SampleId::new("sample:1").unwrap()],
1597 x: MethodsPlsMatrix {
1598 values: vec![1.0, 2.0],
1599 rows: 1,
1600 cols: 2,
1601 },
1602 y: None,
1603 target_names: vec!["protein".to_string()],
1604 },
1605 };
1606 let provider = MethodsPlsPredictDataProvider::new(
1607 ControllerId::new("controller:data.methods.predict").unwrap(),
1608 vec![binding.clone()],
1609 envelopes_for(
1610 &binding,
1611 complete_envelope_with_target_free_fingerprint(
1612 input.data_content_fingerprint.clone(),
1613 ),
1614 ),
1615 BTreeMap::from([(key, input.clone())]),
1616 )
1617 .unwrap();
1618 let request = MethodsPlsDataRequest {
1619 node_id: binding.node_id.clone(),
1620 phase: Phase::Predict,
1621 variant_id: None,
1622 fold_id: None,
1623 binding,
1624 identity: None,
1625 fit_view: DataProviderViewSpec {
1626 sample_ids: Some(input.dataset.sample_ids.clone()),
1627 partition: DataRequestPartition::Predict,
1628 fold_id: None,
1629 source_ids: None,
1630 columns: None,
1631 include_augmented: false,
1632 include_excluded: false,
1633 branch_view: None,
1634 extra: BTreeMap::new(),
1635 },
1636 prediction_view: None,
1637 };
1638 assert_eq!(
1639 provider.methods_pls_data(&request).unwrap().fit,
1640 input.dataset
1641 );
1642
1643 let mut wrong_fingerprint = input;
1644 wrong_fingerprint.data_content_fingerprint = "f".repeat(64);
1645 let error = MethodsPlsPredictDataProvider::new(
1646 ControllerId::new("controller:data.methods.predict").unwrap(),
1647 vec![request.binding.clone()],
1648 envelopes_for(
1649 &request.binding,
1650 complete_envelope_with_target_free_fingerprint(
1651 methods_pls_predict_feature_content_fingerprint(&wrong_fingerprint.dataset.x)
1652 .unwrap(),
1653 ),
1654 ),
1655 BTreeMap::from([(
1656 data_binding_requirement_key(&request.binding.node_id, &request.binding.input_name),
1657 wrong_fingerprint,
1658 )]),
1659 )
1660 .unwrap_err();
1661 assert!(error.to_string().contains("feature content fingerprint"));
1662 }
1663
1664 #[test]
1665 fn methods_pls_predict_content_profile_matches_the_python_reference_vector() {
1666 let fingerprint = methods_pls_predict_feature_content_fingerprint(&MethodsPlsMatrix {
1667 values: vec![1.0, 2.0, 3.0, 4.0],
1668 rows: 2,
1669 cols: 2,
1670 })
1671 .unwrap();
1672 assert_eq!(METHODS_PLS_PREDICT_CONTENT_PROFILE, "n4a-matrix-f64-le.v1");
1673 assert_eq!(
1674 fingerprint,
1675 "ca93722602866b81462d63044d1857ea9acb31ee9532e1a891dcb69a2fd41981"
1676 );
1677 }
1678
1679 fn complete_envelope_with_target_free_fingerprint(
1680 data_content_fingerprint: String,
1681 ) -> ExternalDataPlanEnvelope {
1682 let mut envelope = complete_envelope();
1683 envelope.data_content_fingerprint = Some(data_content_fingerprint);
1684 envelope.target_content_fingerprint = None;
1685 envelope
1686 }
1687}