Skip to main content

dag_ml_core/runtime/
dataview.rs

1// Auto-split from the former monolithic `runtime.rs` (pure refactor).
2use 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    /// The optional, separately attested cohort selected only for a top-level
14    /// PREDICT operation.  It is absent for the V1 path and for every phase
15    /// that can fit, validate, select, refit, or calibrate a model.
16    #[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    /// The same PREDICT-only authority carried by the materialization
305    /// request.  The envelope-attested wrapper compares it exactly before a
306    /// host provider can observe a data view.
307    #[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    /// Attest the exact feature and target content bound to one training input.
315    ///
316    /// Legacy phase execution may return `None`; the native W1 training
317    /// operation requires `Some` and compares it byte-for-byte with the signed
318    /// [`TrainingDataIdentity`](crate::training::TrainingDataIdentity).
319    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    /// Return the separately attested cohort that may be consumed by one
330    /// top-level PREDICT request. It is unavailable to every phase that can
331    /// fit, validate, rank, or calibrate a model.
332    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    /// Confirm that this provider can supply the deliberately narrow numeric
342    /// view consumed by the portable Methods PLS controller.  The default is a
343    /// refusal, so an ordinary data provider can never accidentally expose its
344    /// buffers to a native numerical controller.
345    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    /// Return provider-selected row-major numeric values for a Methods PLS
358    /// invocation.  This is not a raw IO escape hatch: the request carries the
359    /// scheduler-created, identity-keyed data views and the provider is solely
360    /// responsible for resolving them to rows and targets.
361    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/// Row-major `f64` matrix passed from an explicitly capable provider to the
379/// portable Methods PLS controller.  It never crosses the public ABI.
380#[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/// One identity-keyed dataset returned by the portable PLS provider capability.
407#[derive(Clone, Debug, PartialEq)]
408pub struct MethodsPlsDataset {
409    pub sample_ids: Vec<SampleId>,
410    pub x: MethodsPlsMatrix,
411    /// Targets are required for fitting/CV scoring, but deliberately absent
412    /// for production PREDICT.  A predictor must never require labels merely
413    /// to materialize an inference cohort.
414    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/// Scheduler-owned view selection for a portable Methods PLS operation.
460#[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    /// The exact signed data-plan binding selected by the scheduler.  Native
467    /// numerical adapters must not manufacture a dataset from sample IDs.
468    pub binding: DataBinding,
469    /// Complete training identity attested by the provider for `binding`.
470    ///
471    /// FIT_CV and REFIT require this evidence because they fit or score against
472    /// targets. A fresh PREDICT cohort may legitimately be X-only, in which
473    /// case the scheduler's replay-envelope path carries the nullable target
474    /// evidence and this field is `None`. No synthetic target fingerprint is
475    /// permitted to fill that gap.
476    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/// Provider response for one PLS fit/predict invocation.
516#[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
563/// One host-materialized X-only cohort offered to the native Methods PLS
564/// controller for a fresh PREDICT replay.
565///
566/// The feature-content fingerprint is intentionally carried separately from
567/// the numeric matrix: Core binds it to the signed external envelope, while
568/// the production IO provider remains the authority that derives it from its
569/// source bytes. This runtime layer neither synthesizes targets nor invents a
570/// feature-content hash.
571pub const METHODS_PLS_PREDICT_CONTENT_PROFILE: &str = "n4a-matrix-f64-le.v1";
572
573/// Compute the published X-only content identity for a Methods PLS cohort.
574///
575/// The preimage is the ASCII profile plus NUL, two little-endian `u64`
576/// dimensions and each finite IEEE-754 `f64` bit-pattern in row-major order.
577/// It deliberately does not include sample identities or targets: those have
578/// their own signed envelope/relation proofs.
579pub 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    /// Must be [`METHODS_PLS_PREDICT_CONTENT_PROFILE`].
609    pub data_content_profile: String,
610    pub data_content_fingerprint: String,
611    pub dataset: MethodsPlsDataset,
612}
613
614/// Native, in-memory data provider for target-free Methods PLS PREDICT.
615///
616/// It is deliberately PREDICT-only: training must use a provider that can
617/// produce the complete target-bound [`crate::training::TrainingDataIdentity`]. The provider
618/// owns only row-major values already materialized by the upstream IO layer;
619/// it delegates data/view handles and envelope identity checks to the normal
620/// runtime provider rather than bypassing the scheduler.
621#[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    /// `None` is valid only for a target-free fresh PREDICT envelope. Training
801    /// execution requests the identity through `RuntimeDataProvider` and
802    /// rejects absence before any numerical controller is invoked.
803    identity: Option<crate::training::TrainingDataIdentity>,
804}
805
806/// Owns a host data provider while supplying envelope-backed identities at the
807/// runtime trust boundary. A complete training identity is available only
808/// when the envelope carries feature, target and relation fingerprints.
809///
810/// Construction validates the complete binding/envelope set before the inner
811/// provider can be invoked. Runtime calls are delegated only when their full
812/// [`DataBinding`] is field-for-field equal to the binding registered for the
813/// rendered V1 requirement key.
814#[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    /// Export a raw, portable artifact payload after REFIT.  The scheduler
1040    /// immediately transfers this into the durable bundle; implementations
1041    /// must not rely on the returned handle surviving a process boundary.
1042    fn export_artifact_payload(&self, _artifact_id: &ArtifactId) -> Result<Option<Vec<u8>>> {
1043        Ok(None)
1044    }
1045
1046    /// Materialize a durable raw artifact payload in this controller's fresh
1047    /// process-local runtime.  The payload is owned by the bundle; the
1048    /// returned handle is deliberately ephemeral and is only valid for the
1049    /// current replay invocation.  Controllers that do not publish raw
1050    /// portable artifacts keep the default fail-closed implementation.
1051    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    /// Release an invocation-local handle previously returned by
1063    /// [`Self::hydrate_artifact_payload`]. Replay calls this exactly once when
1064    /// execution finishes or aborts; implementations must accept a handle
1065    /// that the controller already consumed during successful invocation.
1066    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    /// Provider-aware execution is opt-in.  Existing controllers retain the
1074    /// opaque-handle path; native Methods controllers can only receive the
1075    /// narrow provider capability above when the scheduler has one.
1076    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    /// Create an execution-local tuner session for one scheduler-owned HPO
1085    /// campaign. The controller stays `Send + Sync` because it is only a
1086    /// factory; the returned session has no Send/Sync bound and may therefore
1087    /// own a thread-affine native context and optimizer.  The scheduler keeps
1088    /// this session on its calling thread and passes only portable proposal and
1089    /// evaluation values across the controller boundary.
1090    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
1113/// Per-campaign tuner state. Deliberately no `Send` or `Sync` supertrait:
1114/// libn4m's Context and Optimizer are thread-affine.  The session proposes a
1115/// portable variant; the scheduler evaluates its FIT_CV/OOF evidence and
1116/// feeds the scalar intermediate/terminal state back here.  This avoids a
1117/// controller-owned CV loop and prevents native state from entering a `Send`
1118/// scheduler worker or registry.
1119pub trait RuntimeTunerSession {
1120    /// Return the complete native study history length, including restored
1121    /// completed, failed and pruned trials. Only the local controller can
1122    /// attest this opaque optimizer state.
1123    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    /// Return the native optimizer incumbent after scheduler terminalization.
1135    /// Implementations must derive it from their optimizer's native `best()`;
1136    /// a coordinator ranking is not an acceptable substitute.
1137    fn incumbent(&self, variants: &BTreeMap<i64, VariantId>)
1138        -> Result<Option<RuntimeHpoIncumbent>>;
1139
1140    /// Return the native trial ledger after terminalization.  This is the
1141    /// sole allowed observation of native status/intermediate/failure state;
1142    /// scheduler and bundle code must never decode N4MOPT bytes themselves.
1143    fn terminal_trial_snapshots(
1144        &self,
1145        variants: &BTreeMap<i64, VariantId>,
1146    ) -> Result<Vec<RuntimeHpoTerminalSnapshot>>;
1147
1148    /// Export the current durable native checkpoint after all scheduler-owned
1149    /// trial transitions have completed. The scheduler validates its binding
1150    /// against the explicit HPO context before exposing it to training.
1151    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
1301/// Scheduler-selected provider inputs for one materialized data view.
1302pub(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    // A data view is delivered to the controller as a data input, so the
1330    // provider must return a data-bearing handle. Refuse a model / artifact /
1331    // prediction / relation handle masquerading as a view across the ABI.
1332    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    // During FIT_CV and REFIT this primary view IS the training input; during
1350    // PREDICT/EXPLAIN (and the planning phases) it is a non-fit read.
1351    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
1366/// Bind a separately attested PREDICT cohort to a scheduler-created view.
1367///
1368/// This replaces, rather than merges with, ordinary partition-derived sample
1369/// identities. Those identities are CV-derived and must never expand a
1370/// held-out external-test cohort. The full cohort travels independently on
1371/// the provider request, where the envelope-attested wrapper verifies it
1372/// before host data is materialized or viewed.
1373pub(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    // This is the validation companion read, never the training input.
1402    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        // An X-only PREDICT cohort deliberately has no training identity. It
1648        // remains fully envelope-bound for materialization and relation use;
1649        // FIT_CV/REFIT reject identity absence in their callers.
1650        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}