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