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