Skip to main content

dag_ml_core/
controller_adapter.rs

1//! Mechanical derivation of `ControllerManifest`s from a thin host-controller
2//! descriptor — the dag-ml side of the `OperatorController -> ControllerManifest`
3//! adapter (`DEC-CTRL-001` / the "B1" adapter).
4//!
5//! ## Why this lives in the core
6//!
7//! Today every host hand-authors a static array of manifest literals (the
8//! nirs4all bridge's `controller_manifests()` ships five) and Studio rebuilds a
9//! *parallel* node registry by walking importable Python classes. Both encode,
10//! by hand, the per-kind facts that are actually deterministic — the
11//! "Inferable" rows of the controller-adapter spec: a `model` node supports
12//! `FIT_CV/REFIT/PREDICT`, fits per fold, emits a prediction and an artifact
13//! port; a `transform` maps `x -> x_out`; and so on. Encoding those facts once,
14//! natively, means every binding (Python / R / WASM / cluster) derives the same
15//! validated manifest for free instead of re-deriving — or drifting from — them.
16//!
17//! ## The two-layer projection
18//!
19//! An `OperatorController.matches()` predicate mixes two independent routing
20//! dimensions that project to different places:
21//!
22//! 1. **keyword / DSL position -> `operator_kind`** ("Layer 1"). This is a
23//!    *compile-time lowering rule* owned by the DSL compiler, not a manifest
24//!    field; by the time a manifest is derived the host already knows the
25//!    [`NodeKind`], so it is an *input* here. Given that kind, this module fills
26//!    in the mechanical defaults via [`manifest_kind_template`].
27//! 2. **operator class / type -> `operator_selectors`** ("Layer 2"). These are
28//!    supplied verbatim by the host as [`OperatorSelector`]s (the existing
29//!    selector vocabulary) and are how a *specialization* manifest (e.g. a
30//!    native PLS controller) out-ranks a generic kind-level catch-all.
31//!
32//! This module invents no new capability/policy vocabulary: a derived manifest
33//! is an ordinary [`ControllerManifest`] over the existing
34//! [`ControllerCapability`] / [`ControllerFitScope`] / [`RngPolicy`] /
35//! [`ArtifactPolicy`] enums, and every derivation is run through
36//! [`ControllerManifest::validate`] before it is returned, so it can never
37//! produce a manifest the registry would reject.
38
39use std::collections::{BTreeMap, BTreeSet};
40
41use serde::{Deserialize, Serialize};
42
43use crate::controller::{
44    ArtifactPolicy, ControllerCapability, ControllerFitScope, ControllerManifest,
45    ControllerRegistry, OperatorSelector, RngPolicy,
46};
47use crate::data::{ModelInputPortSpec, ModelInputSpec, MODEL_INPUT_SPEC_SCHEMA_VERSION};
48use crate::error::{DagMlError, Result};
49use crate::graph::{NodeKind, PortCardinality, PortKind, PortSpec};
50use crate::ids::ControllerId;
51use crate::phase::Phase;
52
53/// Frozen representation id for a generic numeric feature table — the
54/// modality-neutral default stamped on **data** ports of a derived manifest.
55/// Published by the dag-ml-data representation registry
56/// (`docs/contracts/representation_registry.v1.json`, registry id
57/// `dag-ml-data.representation_registry.v1`, `type_id = "table"`).
58pub const REPRESENTATION_TABULAR_NUMERIC: &str = "tabular_numeric";
59
60/// Frozen representation id for a generic numeric target — the modality-neutral
61/// default stamped on **target** ports of a derived manifest. Published by the
62/// same registry (`type_id = "target"`). This replaces the previous coarse
63/// behaviour that (incorrectly) stamped the *feature*-table id on target ports.
64pub const REPRESENTATION_TARGET_NUMERIC: &str = "target_numeric";
65
66/// `(representation_id, type_id)` rows mirrored verbatim from the frozen
67/// dag-ml-data representation registry
68/// (`docs/contracts/representation_registry.v1.json`). dag-ml does not depend on
69/// dag-ml-data, so the registry's MVP-emitted representations — plus the generic
70/// `tabular_*` ids the kind templates default to — are mirrored here as the
71/// in-core source of truth used to synthesize a controller's [`ModelInputSpec`].
72/// The full 26-id list is CI-gated against the sibling registry by the L20
73/// contract-lockstep (`scripts/validate_contracts.py`); this subset is the part
74/// the controller adapter consumes.
75const FROZEN_REPRESENTATION_TYPES: &[(&str, &str)] = &[
76    // Generic, modality-neutral (the kind-template defaults).
77    (REPRESENTATION_TABULAR_NUMERIC, "table"),
78    ("tabular_mixed", "table"),
79    // MVP-emitted data representations (spectra/image profile).
80    ("signal_1d", "dense_signal"),
81    ("signal_with_processings", "dense_signal"),
82    ("feature_block_set", "multi_block"),
83    // MVP-emitted target representations.
84    (REPRESENTATION_TARGET_NUMERIC, "target"),
85    ("target_categorical", "target"),
86    ("target_numeric_matrix", "target"),
87    ("target_categorical_matrix", "target"),
88    // MVP-emitted per-sample metadata.
89    ("sample_metadata", "metadata"),
90];
91
92/// Return the frozen `type_id` registered for `representation_id`, or `None`
93/// when the id is outside the mirrored registry subset. Hosts use this to type a port while
94/// building an explicit `data_requirements` override; the adapter uses it to
95/// synthesize the default one.
96pub fn representation_type_id(representation_id: &str) -> Option<&'static str> {
97    FROZEN_REPRESENTATION_TYPES
98        .iter()
99        .find(|(id, _)| *id == representation_id)
100        .map(|(_, type_id)| *type_id)
101}
102
103/// The mechanical, per-[`NodeKind`] portion of a [`ControllerManifest`]: the
104/// fields a host does *not* need to author because they follow deterministically
105/// from the node kind. [`HostControllerSpec::derive`] composes one of these with
106/// the host-supplied identity/selectors/overrides.
107#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct ManifestKindTemplate {
109    /// Phases the kind participates in.
110    pub supported_phases: BTreeSet<Phase>,
111    /// When fitted state is established.
112    pub fit_scope: ControllerFitScope,
113    /// Capabilities implied by the kind alone (the host may add more).
114    pub capabilities: BTreeSet<ControllerCapability>,
115    /// Default input ports.
116    pub input_ports: Vec<PortSpec>,
117    /// Default output ports.
118    pub output_ports: Vec<PortSpec>,
119}
120
121/// Return the deterministic manifest defaults for `kind`.
122///
123/// Kinds that the current vertical slice binds (`transform`, `y_transform`,
124/// `model`, `prediction_join`) get the exact template the nirs4all bridge hand
125/// authors today; any other kind gets a conservative, always-valid generic
126/// template (training-capable, fold-scoped, no ports) that a host refines with
127/// [`HostControllerSpec`] overrides.
128pub fn manifest_kind_template(kind: &NodeKind) -> ManifestKindTemplate {
129    let training_phases = || BTreeSet::from([Phase::FitCv, Phase::Refit, Phase::Predict]);
130    match kind {
131        NodeKind::Transform => ManifestKindTemplate {
132            supported_phases: training_phases(),
133            fit_scope: ControllerFitScope::FoldTrain,
134            capabilities: stateless_compute_capabilities(),
135            input_ports: vec![represented_port(
136                "x",
137                PortKind::Data,
138                REPRESENTATION_TABULAR_NUMERIC,
139            )],
140            output_ports: vec![represented_port(
141                "x_out",
142                PortKind::Data,
143                REPRESENTATION_TABULAR_NUMERIC,
144            )],
145        },
146        NodeKind::YTransform => ManifestKindTemplate {
147            supported_phases: training_phases(),
148            fit_scope: ControllerFitScope::FoldTrain,
149            capabilities: stateless_compute_capabilities(),
150            input_ports: vec![represented_port(
151                "y",
152                PortKind::Target,
153                REPRESENTATION_TARGET_NUMERIC,
154            )],
155            output_ports: vec![represented_port(
156                "y_out",
157                PortKind::Target,
158                REPRESENTATION_TARGET_NUMERIC,
159            )],
160        },
161        NodeKind::Model => ManifestKindTemplate {
162            supported_phases: training_phases(),
163            fit_scope: ControllerFitScope::FoldTrain,
164            capabilities: {
165                let mut capabilities = stateless_compute_capabilities();
166                capabilities.insert(ControllerCapability::EmitsPredictions);
167                capabilities.insert(ControllerCapability::EmitsArtifacts);
168                capabilities.insert(ControllerCapability::Stateful);
169                capabilities
170            },
171            input_ports: vec![represented_port(
172                "x",
173                PortKind::Data,
174                REPRESENTATION_TABULAR_NUMERIC,
175            )],
176            output_ports: vec![
177                opaque_port("y_hat", PortKind::Prediction, PortCardinality::One),
178                opaque_port("model", PortKind::Artifact, PortCardinality::One),
179            ],
180        },
181        NodeKind::PredictionJoin => ManifestKindTemplate {
182            supported_phases: training_phases(),
183            fit_scope: ControllerFitScope::FoldTrain,
184            capabilities: {
185                let mut capabilities = base_capabilities();
186                capabilities.insert(ControllerCapability::ConsumesOofPredictions);
187                capabilities.insert(ControllerCapability::EmitsPredictions);
188                capabilities
189            },
190            input_ports: vec![opaque_port(
191                "oof",
192                PortKind::Prediction,
193                PortCardinality::Many,
194            )],
195            output_ports: vec![opaque_port(
196                "oof",
197                PortKind::Prediction,
198                PortCardinality::One,
199            )],
200        },
201        _ => ManifestKindTemplate {
202            supported_phases: training_phases(),
203            fit_scope: ControllerFitScope::FoldTrain,
204            capabilities: base_capabilities(),
205            input_ports: Vec::new(),
206            output_ports: Vec::new(),
207        },
208    }
209}
210
211/// Host-side description of one `OperatorController`, the input from which a
212/// validated [`ControllerManifest`] is mechanically derived.
213///
214/// Construct with [`HostControllerSpec::new`] (which fills policy defaults) and
215/// set any explicit overrides on the public fields, then call
216/// [`HostControllerSpec::derive`]. The struct is `serde`-(de)serializable so a
217/// host that drives the core over JSON / PyO3 / the process adapter can ship the
218/// descriptor directly rather than re-implementing the per-kind defaults — the
219/// authoritative wire artifact remains the derived `ControllerManifest`.
220#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
221#[serde(deny_unknown_fields)]
222pub struct HostControllerSpec {
223    /// Stable controller id, e.g. `controller:nirs4all.model`.
224    pub controller_id: String,
225    /// Controller/runtime version (must be non-empty; checked at derive time).
226    pub controller_version: String,
227    /// The node kind this controller serves (Layer-1 lowering output).
228    pub operator_kind: NodeKind,
229    /// Resolution priority (lower wins). Defaults to `0`; nirs4all's bridge
230    /// uses `20` to keep generic host controllers above native specializations.
231    #[serde(default)]
232    pub priority: u32,
233    /// Capabilities to add on top of the kind template (existing vocabulary
234    /// only) — e.g. `needs_python_gil` for a deep-learning model controller, or
235    /// `consumes_oof_predictions` for a stacking meta-model.
236    #[serde(default)]
237    pub added_capabilities: BTreeSet<ControllerCapability>,
238    /// Layer-2 selectors that bind specific operators to this controller. Empty
239    /// makes the manifest a kind-level catch-all.
240    #[serde(default)]
241    pub operator_selectors: Vec<OperatorSelector>,
242    /// RNG policy. Defaults to `uses_core_seed`.
243    #[serde(default = "default_rng_policy")]
244    pub rng_policy: RngPolicy,
245    /// Artifact policy. Defaults to `serializable`.
246    #[serde(default = "default_artifact_policy")]
247    pub artifact_policy: ArtifactPolicy,
248    /// Optional `ModelInputSpec` JSON; validated by the manifest if present.
249    #[serde(default)]
250    pub data_requirements: Option<serde_json::Value>,
251    /// Override the kind template's input ports (e.g. a meta-model consuming an
252    /// `oof` prediction port instead of an `x` data port). `None` keeps the
253    /// template default.
254    #[serde(default)]
255    pub input_ports: Option<Vec<PortSpec>>,
256    /// Override the kind template's output ports. `None` keeps the default.
257    #[serde(default)]
258    pub output_ports: Option<Vec<PortSpec>>,
259}
260
261impl HostControllerSpec {
262    /// A spec with policy/priority defaults and no overrides.
263    pub fn new(
264        controller_id: impl Into<String>,
265        controller_version: impl Into<String>,
266        operator_kind: NodeKind,
267    ) -> Self {
268        Self {
269            controller_id: controller_id.into(),
270            controller_version: controller_version.into(),
271            operator_kind,
272            priority: 0,
273            added_capabilities: BTreeSet::new(),
274            operator_selectors: Vec::new(),
275            rng_policy: default_rng_policy(),
276            artifact_policy: default_artifact_policy(),
277            data_requirements: None,
278            input_ports: None,
279            output_ports: None,
280        }
281    }
282
283    /// Derive the [`ControllerManifest`], applying the kind template, merging
284    /// `added_capabilities`, honoring port overrides, synthesizing
285    /// `data_requirements` from the resolved data/target ports when the host did
286    /// not supply one, and validating the result.
287    ///
288    /// When the host leaves `data_requirements` unset, a [`ModelInputSpec`] is
289    /// synthesized that pins — per data/target input port — the frozen registry
290    /// representation id and its `type_id`; an explicit `data_requirements` is
291    /// always preferred over the synthesized one. Returns
292    /// [`crate::error::DagMlError::ControllerValidation`] (or an invalid
293    /// identifier error) if the composed manifest is not registry-admissible —
294    /// e.g. an empty version, or an output port whose required capability the
295    /// host neither inherited nor added.
296    pub fn derive(&self) -> Result<ControllerManifest> {
297        let ManifestKindTemplate {
298            supported_phases,
299            fit_scope,
300            mut capabilities,
301            input_ports,
302            output_ports,
303        } = manifest_kind_template(&self.operator_kind);
304        capabilities.extend(self.added_capabilities.iter().copied());
305
306        let input_ports = self.input_ports.clone().unwrap_or(input_ports);
307        let output_ports = self.output_ports.clone().unwrap_or(output_ports);
308        let data_requirements = match &self.data_requirements {
309            Some(requirements) => Some(requirements.clone()),
310            None => default_data_requirements(&input_ports)?,
311        };
312
313        let manifest = ControllerManifest {
314            controller_id: ControllerId::new(self.controller_id.clone())?,
315            controller_version: self.controller_version.clone(),
316            operator_kind: self.operator_kind.clone(),
317            priority: self.priority,
318            supported_phases,
319            input_ports,
320            output_ports,
321            data_requirements,
322            capabilities,
323            operator_selectors: self.operator_selectors.clone(),
324            fit_scope,
325            rng_policy: self.rng_policy,
326            artifact_policy: self.artifact_policy,
327        };
328        manifest.validate()?;
329        Ok(manifest)
330    }
331}
332
333/// Derive every spec and register the manifests into a fresh
334/// [`ControllerRegistry`], surfacing the first derivation or duplicate-id error.
335/// This is the one call a runtime needs to turn its declared host controllers
336/// into a resolvable registry — the replacement for a hardcoded static node
337/// registry.
338pub fn derive_host_controller_registry(specs: &[HostControllerSpec]) -> Result<ControllerRegistry> {
339    let mut registry = ControllerRegistry::new();
340    for spec in specs {
341        registry.register(spec.derive()?)?;
342    }
343    Ok(registry)
344}
345
346fn default_rng_policy() -> RngPolicy {
347    RngPolicy::UsesCoreSeed
348}
349
350fn default_artifact_policy() -> ArtifactPolicy {
351    ArtifactPolicy::Serializable
352}
353
354fn base_capabilities() -> BTreeSet<ControllerCapability> {
355    BTreeSet::from([
356        ControllerCapability::Deterministic,
357        ControllerCapability::ThreadSafe,
358        ControllerCapability::ProcessSafe,
359    ])
360}
361
362fn stateless_compute_capabilities() -> BTreeSet<ControllerCapability> {
363    let mut capabilities = base_capabilities();
364    capabilities.insert(ControllerCapability::UsesCoreRng);
365    capabilities
366}
367
368fn represented_port(name: &str, kind: PortKind, representation: &str) -> PortSpec {
369    PortSpec {
370        name: name.to_string(),
371        kind,
372        representation: Some(representation.to_string()),
373        cardinality: PortCardinality::One,
374        unit_level: None,
375        alignment_key: None,
376        target_level: None,
377        description: String::new(),
378    }
379}
380
381/// Synthesize the default `data_requirements` (a [`ModelInputSpec`] encoded as
382/// JSON) describing the data a controller consumes, derived from its resolved
383/// data/target input ports.
384///
385/// Each `Data`/`Target` input port that carries a representation present in the
386/// frozen registry mirror ([`FROZEN_REPRESENTATION_TYPES`]) contributes one
387/// [`ModelInputPortSpec`] accepting that representation id and its registry
388/// `type_id`. Returns `Ok(None)` when the controller declares no such port (e.g.
389/// a prediction-join consuming only OOF predictions) or when a data/target port
390/// carries a representation outside the mirrored subset — in the latter case the
391/// host is expected to supply an explicit `data_requirements`, which
392/// [`HostControllerSpec::derive`] always prefers over this synthesis.
393fn default_data_requirements(input_ports: &[PortSpec]) -> Result<Option<serde_json::Value>> {
394    let mut ports = Vec::new();
395    for port in input_ports {
396        if !matches!(port.kind, PortKind::Data | PortKind::Target) {
397            continue;
398        }
399        let Some(representation) = port.representation.as_deref() else {
400            continue;
401        };
402        let Some(type_id) = representation_type_id(representation) else {
403            return Ok(None);
404        };
405        ports.push(ModelInputPortSpec {
406            name: port.name.clone(),
407            accepted_representations: vec![representation.to_string()],
408            accepted_types: vec![type_id.to_string()],
409            rank: None,
410            multi_source: false,
411            optional: matches!(port.cardinality, PortCardinality::Optional),
412            metadata: BTreeMap::new(),
413        });
414    }
415    if ports.is_empty() {
416        return Ok(None);
417    }
418    let spec = ModelInputSpec {
419        schema_version: MODEL_INPUT_SPEC_SCHEMA_VERSION,
420        ports,
421        default_fusion: None,
422        fit_influence_policy: None,
423        metadata: BTreeMap::new(),
424    };
425    let requirements = serde_json::to_value(&spec).map_err(|error| {
426        DagMlError::ControllerValidation(format!(
427            "failed to encode synthesized data_requirements: {error}"
428        ))
429    })?;
430    Ok(Some(requirements))
431}
432
433fn opaque_port(name: &str, kind: PortKind, cardinality: PortCardinality) -> PortSpec {
434    PortSpec {
435        name: name.to_string(),
436        kind,
437        representation: None,
438        cardinality,
439        unit_level: None,
440        alignment_key: None,
441        target_level: None,
442        description: String::new(),
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use std::collections::BTreeMap;
449
450    use serde_json::json;
451
452    use super::*;
453    use crate::graph::NodeSpec;
454    use crate::ids::NodeId;
455
456    const VERSION: &str = "0.10.0";
457
458    fn capabilities(values: &[ControllerCapability]) -> BTreeSet<ControllerCapability> {
459        values.iter().copied().collect()
460    }
461
462    fn node_with_operator(kind: NodeKind, operator: Option<serde_json::Value>) -> NodeSpec {
463        NodeSpec {
464            id: NodeId::new("node:under-test").unwrap(),
465            kind,
466            operator,
467            params: BTreeMap::new(),
468            ports: crate::graph::PortSchema::default(),
469            metadata: BTreeMap::new(),
470            seed_label: None,
471        }
472    }
473
474    /// The four kind-level catch-alls must reproduce the nirs4all bridge's
475    /// hand-authored manifests field-for-field — this is the parity contract
476    /// that lets the bridge stop hand-writing them.
477    #[test]
478    fn transform_template_matches_bridge_manifest() {
479        let mut spec = HostControllerSpec::new(
480            "controller:nirs4all.transform",
481            VERSION,
482            NodeKind::Transform,
483        );
484        spec.priority = 20;
485        let manifest = spec.derive().expect("transform derives");
486
487        assert_eq!(manifest.operator_kind, NodeKind::Transform);
488        assert_eq!(manifest.priority, 20);
489        assert_eq!(
490            manifest.supported_phases,
491            BTreeSet::from([Phase::FitCv, Phase::Refit, Phase::Predict])
492        );
493        assert_eq!(
494            manifest.capabilities,
495            capabilities(&[
496                ControllerCapability::Deterministic,
497                ControllerCapability::ThreadSafe,
498                ControllerCapability::ProcessSafe,
499                ControllerCapability::UsesCoreRng,
500            ])
501        );
502        assert_eq!(
503            manifest.input_ports,
504            vec![represented_port(
505                "x",
506                PortKind::Data,
507                REPRESENTATION_TABULAR_NUMERIC
508            )]
509        );
510        assert_eq!(
511            manifest.output_ports,
512            vec![represented_port(
513                "x_out",
514                PortKind::Data,
515                REPRESENTATION_TABULAR_NUMERIC
516            )]
517        );
518        assert_eq!(manifest.fit_scope, ControllerFitScope::FoldTrain);
519        assert_eq!(manifest.rng_policy, RngPolicy::UsesCoreSeed);
520        assert_eq!(manifest.artifact_policy, ArtifactPolicy::Serializable);
521        assert!(manifest.operator_selectors.is_empty());
522    }
523
524    #[test]
525    fn y_transform_template_targets_y_ports() {
526        let manifest = HostControllerSpec::new(
527            "controller:nirs4all.y_transform",
528            VERSION,
529            NodeKind::YTransform,
530        )
531        .derive()
532        .expect("y_transform derives");
533
534        assert_eq!(
535            manifest.input_ports,
536            vec![represented_port(
537                "y",
538                PortKind::Target,
539                REPRESENTATION_TARGET_NUMERIC
540            )]
541        );
542        assert_eq!(
543            manifest.output_ports,
544            vec![represented_port(
545                "y_out",
546                PortKind::Target,
547                REPRESENTATION_TARGET_NUMERIC
548            )]
549        );
550        assert_eq!(
551            manifest.capabilities,
552            capabilities(&[
553                ControllerCapability::Deterministic,
554                ControllerCapability::ThreadSafe,
555                ControllerCapability::ProcessSafe,
556                ControllerCapability::UsesCoreRng,
557            ])
558        );
559    }
560
561    #[test]
562    fn model_template_emits_prediction_and_artifact_ports() {
563        let manifest =
564            HostControllerSpec::new("controller:nirs4all.model", VERSION, NodeKind::Model)
565                .derive()
566                .expect("model derives");
567
568        assert_eq!(
569            manifest.capabilities,
570            capabilities(&[
571                ControllerCapability::Deterministic,
572                ControllerCapability::ThreadSafe,
573                ControllerCapability::ProcessSafe,
574                ControllerCapability::UsesCoreRng,
575                ControllerCapability::EmitsPredictions,
576                ControllerCapability::EmitsArtifacts,
577                ControllerCapability::Stateful,
578            ])
579        );
580        assert_eq!(
581            manifest.input_ports,
582            vec![represented_port(
583                "x",
584                PortKind::Data,
585                REPRESENTATION_TABULAR_NUMERIC
586            )]
587        );
588        assert_eq!(
589            manifest.output_ports,
590            vec![
591                opaque_port("y_hat", PortKind::Prediction, PortCardinality::One),
592                opaque_port("model", PortKind::Artifact, PortCardinality::One),
593            ]
594        );
595    }
596
597    #[test]
598    fn prediction_join_template_matches_merge_concat() {
599        let manifest = HostControllerSpec::new(
600            "controller:nirs4all.merge_concat",
601            VERSION,
602            NodeKind::PredictionJoin,
603        )
604        .derive()
605        .expect("prediction_join derives");
606
607        assert_eq!(
608            manifest.capabilities,
609            capabilities(&[
610                ControllerCapability::Deterministic,
611                ControllerCapability::ThreadSafe,
612                ControllerCapability::ProcessSafe,
613                ControllerCapability::ConsumesOofPredictions,
614                ControllerCapability::EmitsPredictions,
615            ])
616        );
617        assert_eq!(
618            manifest.input_ports,
619            vec![opaque_port(
620                "oof",
621                PortKind::Prediction,
622                PortCardinality::Many
623            )]
624        );
625        assert_eq!(
626            manifest.output_ports,
627            vec![opaque_port(
628                "oof",
629                PortKind::Prediction,
630                PortCardinality::One
631            )]
632        );
633    }
634
635    /// A specialization manifest: model kind, but consumes OOF, takes an `oof`
636    /// input port instead of `x`, and carries a `refs` selector so it stays out
637    /// of the generic model catch-all (the meta-model pattern).
638    #[test]
639    fn meta_model_specialization_overrides_ports_and_caps() {
640        let mut spec =
641            HostControllerSpec::new("controller:nirs4all.meta_model", VERSION, NodeKind::Model);
642        spec.priority = 20;
643        spec.added_capabilities
644            .insert(ControllerCapability::ConsumesOofPredictions);
645        spec.input_ports = Some(vec![opaque_port(
646            "oof",
647            PortKind::Prediction,
648            PortCardinality::Many,
649        )]);
650        spec.operator_selectors.push(OperatorSelector {
651            refs: BTreeSet::from(["nirs4all.meta_model".to_string()]),
652            ..OperatorSelector::default()
653        });
654        let manifest = spec.derive().expect("meta_model derives");
655
656        assert_eq!(
657            manifest.capabilities,
658            capabilities(&[
659                ControllerCapability::Deterministic,
660                ControllerCapability::ThreadSafe,
661                ControllerCapability::ProcessSafe,
662                ControllerCapability::UsesCoreRng,
663                ControllerCapability::ConsumesOofPredictions,
664                ControllerCapability::EmitsPredictions,
665                ControllerCapability::EmitsArtifacts,
666                ControllerCapability::Stateful,
667            ])
668        );
669        assert_eq!(
670            manifest.input_ports,
671            vec![opaque_port(
672                "oof",
673                PortKind::Prediction,
674                PortCardinality::Many
675            )]
676        );
677        // Output ports still inherit the model template default.
678        assert_eq!(
679            manifest.output_ports,
680            vec![
681                opaque_port("y_hat", PortKind::Prediction, PortCardinality::One),
682                opaque_port("model", PortKind::Artifact, PortCardinality::One),
683            ]
684        );
685        assert_eq!(manifest.operator_selectors.len(), 1);
686    }
687
688    /// The binding-extension path: a selector-bearing native specialization
689    /// out-ranks the generic kind-level controller for the operators it claims,
690    /// while bare operators still fall through to the generic one.
691    #[test]
692    fn selector_specialization_outranks_generic_in_registry() {
693        let mut pls = HostControllerSpec::new("controller:methods.pls", VERSION, NodeKind::Model);
694        pls.priority = 10;
695        pls.operator_selectors.push(OperatorSelector {
696            aliases: BTreeSet::from(["PLSRegression".to_string(), "PLS".to_string()]),
697            ..OperatorSelector::default()
698        });
699        let registry = derive_host_controller_registry(&[
700            HostControllerSpec::new("controller:nirs4all.model", VERSION, NodeKind::Model),
701            pls,
702        ])
703        .expect("registry derives");
704
705        let pls_node = node_with_operator(NodeKind::Model, Some(json!({"class": "PLSRegression"})));
706        assert_eq!(
707            registry
708                .resolve_for_node(&pls_node)
709                .unwrap()
710                .controller_id
711                .as_str(),
712            "controller:methods.pls"
713        );
714
715        let generic_node = node_with_operator(NodeKind::Model, Some(json!({"class": "Ridge"})));
716        assert_eq!(
717            registry
718                .resolve_for_node(&generic_node)
719                .unwrap()
720                .controller_id
721                .as_str(),
722            "controller:nirs4all.model"
723        );
724    }
725
726    #[test]
727    fn derive_propagates_validation_failure_for_empty_version() {
728        let spec = HostControllerSpec::new("controller:nirs4all.model", "", NodeKind::Model);
729        let error = spec.derive().unwrap_err().to_string();
730        assert!(error.contains("empty version"), "unexpected error: {error}");
731    }
732
733    /// Overrides are validated too: a prediction output port on a transform
734    /// (whose template lacks `emits_predictions`) is rejected.
735    #[test]
736    fn derive_rejects_override_that_violates_capability_invariant() {
737        let mut spec =
738            HostControllerSpec::new("controller:bad.transform", VERSION, NodeKind::Transform);
739        spec.output_ports = Some(vec![opaque_port(
740            "leak",
741            PortKind::Prediction,
742            PortCardinality::One,
743        )]);
744        let error = spec.derive().unwrap_err().to_string();
745        assert!(
746            error.contains("lacks emits_predictions"),
747            "unexpected error: {error}"
748        );
749    }
750
751    #[test]
752    fn generic_template_for_unmapped_kind_validates() {
753        // A kind with no bespoke template still derives a valid, generic manifest.
754        let manifest = HostControllerSpec::new("controller:host.tag", VERSION, NodeKind::Tag)
755            .derive()
756            .expect("tag derives");
757        assert!(manifest.input_ports.is_empty());
758        assert!(manifest.output_ports.is_empty());
759        assert_eq!(
760            manifest.capabilities,
761            capabilities(&[
762                ControllerCapability::Deterministic,
763                ControllerCapability::ThreadSafe,
764                ControllerCapability::ProcessSafe,
765            ])
766        );
767    }
768
769    #[test]
770    fn host_controller_spec_round_trips_through_json() {
771        let mut spec =
772            HostControllerSpec::new("controller:nirs4all.model", VERSION, NodeKind::Model);
773        spec.priority = 20;
774        spec.added_capabilities
775            .insert(ControllerCapability::NeedsPythonGil);
776        let encoded = serde_json::to_string(&spec).expect("encode");
777        let decoded: HostControllerSpec = serde_json::from_str(&encoded).expect("decode");
778        assert_eq!(spec, decoded);
779        // And the descriptor decoded from the wire derives the same manifest.
780        assert_eq!(spec.derive().unwrap(), decoded.derive().unwrap());
781    }
782
783    #[test]
784    fn minimal_json_descriptor_applies_defaults() {
785        // Only the three required fields; policies/priority/ports defaulted.
786        let spec: HostControllerSpec = serde_json::from_value(json!({
787            "controller_id": "controller:nirs4all.transform",
788            "controller_version": VERSION,
789            "operator_kind": "transform",
790        }))
791        .expect("decode minimal");
792        assert_eq!(spec.priority, 0);
793        assert_eq!(spec.rng_policy, RngPolicy::UsesCoreSeed);
794        assert_eq!(spec.artifact_policy, ArtifactPolicy::Serializable);
795        let manifest = spec.derive().expect("derives");
796        assert_eq!(manifest.operator_kind, NodeKind::Transform);
797    }
798
799    // --- B-014b: data/target ports carry frozen registry representation ids,
800    //     and `data_requirements` is synthesized as a validated ModelInputSpec.
801
802    /// The frozen-registry mirror maps the ids it publishes to the registry's
803    /// `type_id`, and reports nothing for ids outside the subset.
804    #[test]
805    fn representation_type_id_maps_frozen_registry_ids() {
806        assert_eq!(
807            representation_type_id(REPRESENTATION_TABULAR_NUMERIC),
808            Some("table")
809        );
810        assert_eq!(
811            representation_type_id(REPRESENTATION_TARGET_NUMERIC),
812            Some("target")
813        );
814        assert_eq!(representation_type_id("signal_1d"), Some("dense_signal"));
815        assert_eq!(
816            representation_type_id("feature_block_set"),
817            Some("multi_block")
818        );
819        assert_eq!(representation_type_id("sample_metadata"), Some("metadata"));
820        assert_eq!(representation_type_id("not_a_real_representation"), None);
821    }
822
823    /// A model's `x` data port now carries the generic `tabular_numeric` id and
824    /// `derive()` synthesizes a matching, validated `ModelInputSpec`.
825    #[test]
826    fn model_template_synthesizes_tabular_data_requirements() {
827        let manifest =
828            HostControllerSpec::new("controller:nirs4all.model", VERSION, NodeKind::Model)
829                .derive()
830                .expect("model derives");
831        assert_eq!(
832            manifest.input_ports[0].representation.as_deref(),
833            Some(REPRESENTATION_TABULAR_NUMERIC)
834        );
835        let spec = manifest
836            .model_input_spec()
837            .expect("data_requirements parse")
838            .expect("model has synthesized data_requirements");
839        assert_eq!(spec.schema_version, MODEL_INPUT_SPEC_SCHEMA_VERSION);
840        assert_eq!(spec.ports.len(), 1);
841        assert_eq!(spec.ports[0].name, "x");
842        assert_eq!(
843            spec.ports[0].accepted_representations,
844            vec![REPRESENTATION_TABULAR_NUMERIC.to_string()]
845        );
846        assert_eq!(spec.ports[0].accepted_types, vec!["table".to_string()]);
847        assert!(!spec.ports[0].optional);
848    }
849
850    /// A y_transform's `y` port now carries the `target_numeric` id (not the
851    /// feature-table id it wrongly used before) and the synthesized
852    /// `data_requirements` pins the target representation.
853    #[test]
854    fn y_transform_synthesizes_target_data_requirements() {
855        let manifest = HostControllerSpec::new(
856            "controller:nirs4all.y_transform",
857            VERSION,
858            NodeKind::YTransform,
859        )
860        .derive()
861        .expect("y_transform derives");
862        assert_eq!(
863            manifest.input_ports[0].representation.as_deref(),
864            Some(REPRESENTATION_TARGET_NUMERIC)
865        );
866        let spec = manifest
867            .model_input_spec()
868            .expect("data_requirements parse")
869            .expect("y_transform has synthesized data_requirements");
870        assert_eq!(spec.ports.len(), 1);
871        assert_eq!(spec.ports[0].name, "y");
872        assert_eq!(
873            spec.ports[0].accepted_representations,
874            vec![REPRESENTATION_TARGET_NUMERIC.to_string()]
875        );
876        assert_eq!(spec.ports[0].accepted_types, vec!["target".to_string()]);
877    }
878
879    /// A prediction-join consumes only OOF predictions (an opaque port), so it
880    /// has no data/target requirement to synthesize.
881    #[test]
882    fn prediction_join_has_no_data_requirements() {
883        let manifest = HostControllerSpec::new(
884            "controller:nirs4all.merge_concat",
885            VERSION,
886            NodeKind::PredictionJoin,
887        )
888        .derive()
889        .expect("prediction_join derives");
890        assert!(manifest.data_requirements.is_none());
891        assert!(manifest.model_input_spec().unwrap().is_none());
892    }
893
894    /// An explicit host-supplied `data_requirements` is preserved verbatim — the
895    /// adapter never overwrites it with the synthesized default.
896    #[test]
897    fn host_supplied_data_requirements_take_precedence_over_synthesis() {
898        let mut spec =
899            HostControllerSpec::new("controller:nirs4all.model", VERSION, NodeKind::Model);
900        spec.data_requirements = Some(json!({
901            "schema_version": 1,
902            "ports": [{
903                "name": "x",
904                "accepted_representations": ["signal_1d", "signal_with_processings"],
905                "accepted_types": ["dense_signal"],
906            }],
907        }));
908        let manifest = spec.derive().expect("model derives");
909        let parsed = manifest
910            .model_input_spec()
911            .expect("parse")
912            .expect("present");
913        assert_eq!(
914            parsed.ports[0].accepted_representations,
915            vec![
916                "signal_1d".to_string(),
917                "signal_with_processings".to_string()
918            ]
919        );
920    }
921
922    /// Synthesis follows the *resolved* ports: overriding the data port to a
923    /// different frozen id (e.g. the NIRS `signal_1d`) re-pins the synthesized
924    /// `data_requirements` to that id and its registry `type_id`.
925    #[test]
926    fn port_override_with_known_representation_resyncs_data_requirements() {
927        let mut spec = HostControllerSpec::new("controller:methods.pls", VERSION, NodeKind::Model);
928        spec.input_ports = Some(vec![represented_port("x", PortKind::Data, "signal_1d")]);
929        let manifest = spec.derive().expect("model derives");
930        let parsed = manifest.model_input_spec().unwrap().expect("present");
931        assert_eq!(parsed.ports.len(), 1);
932        assert_eq!(
933            parsed.ports[0].accepted_representations,
934            vec!["signal_1d".to_string()]
935        );
936        assert_eq!(
937            parsed.ports[0].accepted_types,
938            vec!["dense_signal".to_string()]
939        );
940    }
941
942    /// A representation outside the mirrored registry subset cannot be typed, so
943    /// synthesis is skipped and the host is expected to supply requirements.
944    #[test]
945    fn port_override_with_unknown_representation_skips_synthesis() {
946        let mut spec = HostControllerSpec::new("controller:methods.pls", VERSION, NodeKind::Model);
947        spec.input_ports = Some(vec![represented_port(
948            "x",
949            PortKind::Data,
950            "totally_unregistered_representation",
951        )]);
952        let manifest = spec.derive().expect("model derives");
953        assert!(
954            manifest.data_requirements.is_none(),
955            "unknown representation must not be auto-typed"
956        );
957    }
958}