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