Skip to main content

dag_ml_core/
hpo.rs

1//! Native Methods HPO controller.
2//!
3//! This is deliberately a controller-owned bridge: a study owns exactly one
4//! official `n4m::Optimizer`, never accepts a caller supplied optimizer, and
5//! does not implement any sampling or pruning algorithm itself.  DAG-ML keeps
6//! fold/influence/lineage/score/selection/refit coordination at the evaluator
7//! boundary; `libn4m` owns only the optimizer state machine.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13
14use crate::campaign::stable_json_fingerprint;
15use crate::canonical::parse_typed_json;
16use crate::fold::FoldSet;
17use crate::metrics::ScoreSet;
18use crate::plan::CampaignSpec;
19use crate::runtime::InMemoryLineageRecorder;
20use crate::selection::{RefitStrategy, SelectionPolicy};
21use crate::training::{TrainingInfluenceKind, TrainingInfluenceManifest};
22
23pub const HPO_MANIFEST_SCHEMA_VERSION: u32 = 1;
24pub const N4MOPT_CHECKPOINT_SCHEMA_VERSION: u32 = 1;
25pub const N4MOPT_ARTIFACT_KIND: &str = "n4m_optimizer_checkpoint";
26pub const N4MOPT_FORMAT: &str = "N4MOPT";
27/// This mirrors the bound enforced by the official Rust binding and native
28/// decoder. Check it before any checkpoint is passed to a native loader.
29pub const MAX_N4MOPT_CHECKPOINT_BYTES: usize = 64 * 1024 * 1024;
30
31/// Resume package bytes are transport input for a scheduler campaign, not a
32/// semantic predictor/campaign coordinate.  Exclude only that opaque field
33/// from HPO provenance so a package can resume the exact same campaign while
34/// every graph, fold, controller, study and search-space binding remains
35/// attested independently.
36pub(crate) fn campaign_provenance_fingerprint(campaign: &CampaignSpec) -> crate::Result<String> {
37    let mut canonical = campaign.clone();
38    if let Some(serde_json::Value::Object(operation)) =
39        canonical.metadata.get_mut("methods_hpo_operation")
40    {
41        operation.remove("resume_package_json");
42        // The requested total is a scheduler budget. It may legitimately grow
43        // on resume and is not part of the immutable campaign provenance.
44        operation.remove("trials");
45    }
46    stable_json_fingerprint(&canonical)
47}
48
49pub type HpoResult<T> = std::result::Result<T, HpoError>;
50
51/// Native error data is retained verbatim enough for policy/retry decisions;
52/// never flatten it into an opaque display string.
53#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct HpoNativeError {
56    pub status: i32,
57    pub kind: String,
58    pub message: String,
59    pub retryable: bool,
60}
61
62#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63#[serde(tag = "code", rename_all = "snake_case")]
64pub enum HpoError {
65    MethodsOptimizerFeatureDisabled,
66    RuntimeConfiguration {
67        reason: String,
68    },
69    InvalidManifest {
70        reason: String,
71    },
72    InvalidSearchSpace {
73        reason: String,
74    },
75    InvalidTrial {
76        reason: String,
77    },
78    InvalidCheckpoint {
79        reason: String,
80    },
81    CheckpointBindingMismatch {
82        reason: String,
83    },
84    Native {
85        operation: String,
86        error: HpoNativeError,
87    },
88    PartialBatch {
89        committed: Vec<HpoTrial>,
90        error: HpoNativeError,
91    },
92    Evaluation {
93        reason: String,
94    },
95}
96
97impl std::fmt::Display for HpoError {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        match self {
100            Self::MethodsOptimizerFeatureDisabled => f.write_str(
101                "Methods optimizer support is disabled; enable the published `methods-optimizer` feature",
102            ),
103            Self::RuntimeConfiguration { reason } => {
104                write!(f, "invalid Methods runtime configuration: {reason}")
105            }
106            Self::InvalidManifest { reason } => write!(f, "invalid HPO manifest: {reason}"),
107            Self::InvalidSearchSpace { reason } => write!(f, "invalid HPO search space: {reason}"),
108            Self::InvalidTrial { reason } => write!(f, "invalid native HPO trial: {reason}"),
109            Self::InvalidCheckpoint { reason } => write!(f, "invalid N4MOPT checkpoint: {reason}"),
110            Self::CheckpointBindingMismatch { reason } => write!(f, "checkpoint binding mismatch: {reason}"),
111            Self::Native { operation, error } => write!(f, "n4m {operation} failed ({}/{}): {}", error.kind, error.status, error.message),
112            Self::PartialBatch { committed, error } => write!(f, "n4m ask_batch committed {} trial(s), then failed ({}/{}): {}", committed.len(), error.kind, error.status, error.message),
113            Self::Evaluation { reason } => write!(f, "HPO evaluation failed: {reason}"),
114        }
115    }
116}
117impl std::error::Error for HpoError {}
118
119#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
120#[serde(deny_unknown_fields)]
121pub struct HpoStudyBinding {
122    pub controller_id: String,
123    pub study_id: String,
124    pub search_space_fingerprint: String,
125    pub optimizer_fingerprint: String,
126}
127
128impl HpoStudyBinding {
129    pub fn validate(&self) -> HpoResult<()> {
130        for (field, value) in [
131            ("controller_id", &self.controller_id),
132            ("study_id", &self.study_id),
133            ("search_space_fingerprint", &self.search_space_fingerprint),
134            ("optimizer_fingerprint", &self.optimizer_fingerprint),
135        ] {
136            if value.trim().is_empty() {
137                return Err(HpoError::InvalidManifest {
138                    reason: format!("{field} must not be empty"),
139                });
140            }
141        }
142        Ok(())
143    }
144}
145
146#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct MethodsHpoControllerManifest {
149    pub schema_version: u32,
150    pub binding: HpoStudyBinding,
151}
152
153impl MethodsHpoControllerManifest {
154    pub fn validate(&self) -> HpoResult<()> {
155        if self.schema_version != HPO_MANIFEST_SCHEMA_VERSION {
156            return Err(HpoError::InvalidManifest {
157                reason: format!(
158                    "unsupported schema_version {}; expected {HPO_MANIFEST_SCHEMA_VERSION}",
159                    self.schema_version
160                ),
161            });
162        }
163        self.binding.validate()
164    }
165}
166
167/// Ordered declaration is intentional: it is part of replay identity and is
168/// retained in every trial, unlike a map's lexical order.
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
170#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
171pub enum HpoParameter {
172    Int {
173        name: String,
174        low: i64,
175        high: i64,
176        step: i64,
177        log: bool,
178    },
179    Float {
180        name: String,
181        low: f64,
182        high: f64,
183        step: f64,
184        log: bool,
185    },
186    Categorical {
187        name: String,
188        values: Vec<HpoCategory>,
189    },
190    Ordinal {
191        name: String,
192        values: Vec<f64>,
193    },
194    SortedTuple {
195        name: String,
196        length: i32,
197        low: f64,
198        high: f64,
199        integer: bool,
200    },
201}
202
203impl HpoParameter {
204    fn name(&self) -> &str {
205        match self {
206            Self::Int { name, .. }
207            | Self::Float { name, .. }
208            | Self::Categorical { name, .. }
209            | Self::Ordinal { name, .. }
210            | Self::SortedTuple { name, .. } => name,
211        }
212    }
213    fn output_names(&self) -> Vec<String> {
214        match self {
215            Self::SortedTuple { name, length, .. } => (0..*length)
216                .map(|index| format!("{name}#{index}"))
217                .collect(),
218            _ => vec![self.name().to_string()],
219        }
220    }
221    fn validate(&self) -> HpoResult<()> {
222        if self.name().trim().is_empty() {
223            return Err(HpoError::InvalidSearchSpace {
224                reason: "parameter name must not be empty".to_string(),
225            });
226        }
227        match self {
228            Self::Int {
229                low, high, step, ..
230            } if low > high || *step <= 0 => Err(HpoError::InvalidSearchSpace {
231                reason: format!(
232                    "integer parameter `{}` has invalid bounds or step",
233                    self.name()
234                ),
235            }),
236            Self::Float {
237                low, high, step, ..
238            } if !low.is_finite()
239                || !high.is_finite()
240                || !step.is_finite()
241                || low > high
242                || *step < 0.0 =>
243            {
244                Err(HpoError::InvalidSearchSpace {
245                    reason: format!(
246                        "float parameter `{}` has invalid bounds or step",
247                        self.name()
248                    ),
249                })
250            }
251            Self::Categorical { values, .. } if values.is_empty() => {
252                Err(HpoError::InvalidSearchSpace {
253                    reason: format!("categorical parameter `{}` has no values", self.name()),
254                })
255            }
256            Self::Ordinal { values, .. }
257                if values.is_empty() || values.iter().any(|value| !value.is_finite()) =>
258            {
259                Err(HpoError::InvalidSearchSpace {
260                    reason: format!("ordinal parameter `{}` is invalid", self.name()),
261                })
262            }
263            Self::SortedTuple {
264                length, low, high, ..
265            } if *length <= 0 || !low.is_finite() || !high.is_finite() || low > high => {
266                Err(HpoError::InvalidSearchSpace {
267                    reason: format!("sorted tuple parameter `{}` is invalid", self.name()),
268                })
269            }
270            _ => Ok(()),
271        }
272    }
273}
274
275#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
276#[serde(rename_all = "snake_case", untagged)]
277pub enum HpoCategory {
278    String(String),
279    Integer(i64),
280    Float(f64),
281    Boolean(bool),
282}
283
284#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct HpoSearchSpace {
287    pub parameters: Vec<HpoParameter>,
288}
289
290impl HpoSearchSpace {
291    pub fn validate(&self) -> HpoResult<()> {
292        if self.parameters.is_empty() {
293            return Err(HpoError::InvalidSearchSpace {
294                reason: "search space has no parameters".to_string(),
295            });
296        }
297        let mut names = BTreeSet::new();
298        for parameter in &self.parameters {
299            parameter.validate()?;
300            for name in parameter.output_names() {
301                if !names.insert(name.clone()) {
302                    return Err(HpoError::InvalidSearchSpace {
303                        reason: format!("duplicate emitted parameter `{name}`"),
304                    });
305                }
306            }
307        }
308        Ok(())
309    }
310    /// TCV1 makes this digest independent of JSON object ordering while
311    /// preserving the declared parameter array order.
312    pub fn fingerprint(&self) -> HpoResult<String> {
313        self.validate()?;
314        let json = serde_json::to_string(self).map_err(|error| HpoError::InvalidSearchSpace {
315            reason: error.to_string(),
316        })?;
317        parse_typed_json(&json)
318            .and_then(|value| value.fingerprint())
319            .map_err(|error| HpoError::InvalidSearchSpace {
320                reason: format!("cannot canonically fingerprint search space: {error}"),
321            })
322    }
323}
324
325/// Configuration that creates one official native optimizer. The production
326/// binding is dynamically loaded from an explicit caller-supplied library
327/// path; it never links to a sibling checkout at build time.
328#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct MethodsHpoStudyConfig {
331    pub controller_id: String,
332    pub study_id: String,
333    /// Runtime identity obtained from the ABI-matched Methods deployment.
334    /// The current n4m binding validates compatibility during Context creation
335    /// but does not expose the negotiated identity as a public accessor.
336    pub methods_abi: String,
337    pub search_space: HpoSearchSpace,
338    pub optimizer: HpoOptimizerConfig,
339}
340
341impl MethodsHpoStudyConfig {
342    #[cfg(feature = "methods-optimizer")]
343    fn methods_abi_identity(&self) -> HpoResult<String> {
344        if self.methods_abi.trim().is_empty() {
345            return Err(HpoError::InvalidManifest {
346                reason: "Methods ABI identity must be supplied by the native controller"
347                    .to_string(),
348            });
349        }
350        Ok(self.methods_abi.clone())
351    }
352}
353
354#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
355#[serde(deny_unknown_fields)]
356pub struct HpoOptimizerConfig {
357    pub sampler: HpoSampler,
358    pub pruner: HpoPruner,
359    pub direction: HpoDirection,
360    pub metric: HpoMetric,
361    pub seed: u64,
362    pub n_startup_trials: i32,
363    pub max_resource: i32,
364    pub reduction_factor: i32,
365}
366
367impl HpoOptimizerConfig {
368    #[cfg(feature = "methods-optimizer")]
369    fn fingerprint(&self) -> HpoResult<String> {
370        let json = serde_json::to_string(self).map_err(|error| HpoError::InvalidManifest {
371            reason: error.to_string(),
372        })?;
373        parse_typed_json(&json)
374            .and_then(|value| value.fingerprint())
375            .map_err(|error| HpoError::InvalidManifest {
376                reason: format!("cannot canonically fingerprint optimizer configuration: {error}"),
377            })
378    }
379}
380
381#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum HpoSampler {
384    Random,
385    Sobol,
386    Lhs,
387    Ternary,
388    Ga,
389    Pso,
390    Cmaes,
391    Tpe,
392    GpEi,
393}
394#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
395#[serde(rename_all = "snake_case")]
396pub enum HpoPruner {
397    None,
398    Median,
399    Asha,
400    Hyperband,
401    Racing,
402}
403#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
404#[serde(rename_all = "snake_case")]
405pub enum HpoDirection {
406    Auto,
407    Minimize,
408    Maximize,
409}
410#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412pub enum HpoMetric {
413    Rmse,
414    Mse,
415    Mae,
416    R2,
417    Accuracy,
418    BalancedAccuracy,
419    F1,
420    Logloss,
421}
422
423#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
424#[serde(deny_unknown_fields)]
425pub struct HpoTrialParameter {
426    pub name: String,
427    pub value: f64,
428    /// Retains the native type so parameter projection cannot turn an
429    /// integer/category into a floating-point patch.
430    #[serde(default)]
431    pub native_kind: Option<HpoNativeParameterKind>,
432    #[serde(default)]
433    pub category_type: Option<HpoCategoryType>,
434    #[serde(default)]
435    pub integer: bool,
436    pub active: bool,
437    pub category_index: Option<i32>,
438    pub category_label: Option<String>,
439}
440
441#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
442#[serde(rename_all = "snake_case")]
443pub enum HpoNativeParameterKind {
444    Int,
445    Float,
446    LogInt,
447    LogFloat,
448    Categorical,
449    Ordinal,
450    SortedTuple,
451}
452
453#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
454#[serde(rename_all = "snake_case")]
455pub enum HpoCategoryType {
456    String,
457    Integer,
458    Float,
459    Boolean,
460}
461
462#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
463#[serde(rename_all = "snake_case")]
464pub enum HpoTrialStatus {
465    Running,
466    Completed,
467    Pruned,
468    Failed,
469    Cancelled,
470}
471
472#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
473#[serde(deny_unknown_fields)]
474pub struct HpoFailure {
475    pub code: String,
476    pub message: String,
477    pub retryable: bool,
478}
479
480#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
481#[serde(deny_unknown_fields)]
482pub struct HpoIntermediate {
483    pub sequence: i64,
484    pub step: i32,
485    pub score: f64,
486    pub should_prune: bool,
487}
488
489#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
490#[serde(deny_unknown_fields)]
491pub struct HpoTrial {
492    pub id: i64,
493    pub ask_sequence: i64,
494    pub terminal_sequence: Option<i64>,
495    pub parameters: BTreeMap<String, HpoTrialParameter>,
496    pub parameter_order: Vec<String>,
497    pub status: HpoTrialStatus,
498    pub score: Option<f64>,
499    pub rung: i32,
500    pub duration: f64,
501    pub intermediates: Vec<HpoIntermediate>,
502    pub failure: Option<HpoFailure>,
503}
504
505/// Normalize both sides of a native-ledger comparison through the same strict
506/// JSON/TCV1 preimage.  The opaque N4MOPT checkpoint is an independent native
507/// serializer; it can restore a binary64 score one ULP away from the Rust JSON
508/// spelling without changing any optimizer decision.  Everything except a
509/// score remains byte-for-byte structural evidence; score comparisons are
510/// deliberately limited to one ULP below.
511#[cfg(any(test, feature = "methods-optimizer"))]
512fn canonical_hpo_terminal_ledger(trials: Vec<HpoTrial>) -> crate::Result<Vec<HpoTrial>> {
513    let json = serde_json::to_string(&trials)?;
514    parse_typed_json(&json).map_err(|error| {
515        crate::DagMlError::RuntimeValidation(format!(
516            "native Methods HPO terminal ledger has no strict TCV1 JSON preimage: {error}"
517        ))
518    })?;
519    Ok(serde_json::from_str(&json)?)
520}
521
522#[cfg(any(test, feature = "methods-optimizer"))]
523fn scores_within_one_ulp(left: f64, right: f64) -> bool {
524    if left == right {
525        return true;
526    }
527    if !left.is_finite() || !right.is_finite() {
528        return false;
529    }
530    let ordered = |value: f64| {
531        let bits = value.to_bits();
532        if bits & (1_u64 << 63) != 0 {
533            (!bits) as i128
534        } else {
535            (bits | (1_u64 << 63)) as i128
536        }
537    };
538    (ordered(left) - ordered(right)).abs() <= 1
539}
540
541#[cfg(any(test, feature = "methods-optimizer"))]
542fn optional_scores_within_one_ulp(left: Option<f64>, right: Option<f64>) -> bool {
543    match (left, right) {
544        (Some(left), Some(right)) => scores_within_one_ulp(left, right),
545        (None, None) => true,
546        _ => false,
547    }
548}
549
550#[cfg(any(test, feature = "methods-optimizer"))]
551fn hpo_terminal_trials_match(native: &[HpoTrial], persisted: &[HpoTrial]) -> bool {
552    native.len() == persisted.len()
553        && native.iter().zip(persisted).all(|(native, persisted)| {
554            native.id == persisted.id
555                && native.ask_sequence == persisted.ask_sequence
556                && native.terminal_sequence == persisted.terminal_sequence
557                && native.parameters == persisted.parameters
558                && native.parameter_order == persisted.parameter_order
559                && native.status == persisted.status
560                && optional_scores_within_one_ulp(native.score, persisted.score)
561                && native.rung == persisted.rung
562                && native.duration == persisted.duration
563                && native.failure == persisted.failure
564                && native.intermediates.len() == persisted.intermediates.len()
565                && native
566                    .intermediates
567                    .iter()
568                    .zip(&persisted.intermediates)
569                    .all(|(native, persisted)| {
570                        native.sequence == persisted.sequence
571                            && native.step == persisted.step
572                            && native.should_prune == persisted.should_prune
573                            && scores_within_one_ulp(native.score, persisted.score)
574                    })
575        })
576}
577
578/// The native optimizer's incumbent, retaining the score returned by its
579/// `best` call rather than re-deriving it from a lossy projection.
580#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
581#[serde(deny_unknown_fields)]
582pub struct HpoBestTrial {
583    pub trial: HpoTrial,
584    pub score: f64,
585}
586
587#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
588#[serde(tag = "event", rename_all = "snake_case", deny_unknown_fields)]
589pub enum HpoEvent {
590    Asked {
591        trial_id: i64,
592    },
593    Intermediate {
594        trial_id: i64,
595        step: i32,
596        score: f64,
597        should_prune: bool,
598    },
599    Terminal {
600        trial_id: i64,
601        status: HpoTrialStatus,
602        score: Option<f64>,
603        failure: Option<HpoFailure>,
604    },
605}
606
607/// Explicit evaluator boundary. It exposes DAG-ML's real ownership primitives,
608/// not marker enums; the optimizer cannot manufacture folds, scores, lineage,
609/// a selection decision, or a refit. Native finetuning remains selection-only.
610pub struct HpoEvaluationBoundary<'a> {
611    pub folds: &'a FoldSet,
612    pub influence: &'a TrainingInfluenceManifest,
613    pub lineage: &'a mut InMemoryLineageRecorder,
614    pub scores: &'a mut ScoreSet,
615    pub selection: &'a SelectionPolicy,
616    pub refit_strategy: Option<RefitStrategy>,
617}
618
619impl HpoEvaluationBoundary<'_> {
620    pub fn validate(&self) -> HpoResult<()> {
621        self.folds
622            .validate()
623            .map_err(|error| HpoError::Evaluation {
624                reason: error.to_string(),
625            })?;
626        self.scores
627            .validate()
628            .map_err(|error| HpoError::Evaluation {
629                reason: error.to_string(),
630            })?;
631        self.selection
632            .validate()
633            .map_err(|error| HpoError::Evaluation {
634                reason: error.to_string(),
635            })?;
636        self.influence
637            .validate()
638            .map_err(|error| HpoError::Evaluation {
639                reason: error.to_string(),
640            })?;
641        if !self
642            .influence
643            .entries
644            .iter()
645            .any(|entry| entry.kind == TrainingInfluenceKind::HpoSelection)
646        {
647            return Err(HpoError::Evaluation {
648                reason: "training influence manifest has no hpo_selection entry".to_string(),
649            });
650        }
651        Ok(())
652    }
653}
654
655pub trait HpoEvaluator {
656    fn evaluate(
657        &mut self,
658        trial: &HpoTrial,
659        boundary: &mut HpoEvaluationBoundary<'_>,
660    ) -> HpoResult<HpoTerminal>;
661
662    /// Override when evaluation has epochs/resources to report. The default
663    /// preserves simple evaluators while allowing native pruning to be decided
664    /// by Methods during the trial rather than after a leaked final score.
665    fn evaluate_with_reporter(
666        &mut self,
667        trial: &HpoTrial,
668        boundary: &mut HpoEvaluationBoundary<'_>,
669        _reporter: &mut dyn HpoIntermediateReporter,
670    ) -> HpoResult<HpoTerminal> {
671        self.evaluate(trial, boundary)
672    }
673}
674
675pub trait HpoIntermediateReporter {
676    fn report(&mut self, step: i32, score: f64) -> HpoResult<HpoReportOutcome>;
677}
678
679#[derive(Clone, Debug, PartialEq)]
680pub enum HpoReportOutcome {
681    Continue,
682    Pruned(HpoTrial),
683}
684
685#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
686#[serde(tag = "state", rename_all = "snake_case")]
687pub enum HpoTerminal {
688    Completed { score: f64 },
689    Failed { failure: HpoFailure },
690    Pruned { failure: HpoFailure },
691    Cancelled { failure: HpoFailure },
692}
693
694#[derive(Clone, Debug, PartialEq)]
695pub struct HpoBatch {
696    pub trials: Vec<HpoTrial>,
697    pub native_error: Option<HpoNativeError>,
698}
699
700#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
701#[serde(deny_unknown_fields)]
702pub struct N4moptCheckpointArtifact {
703    pub schema_version: u32,
704    pub artifact_kind: String,
705    pub format: String,
706    pub binding: HpoStudyBinding,
707    pub methods_abi: String,
708    pub opaque_payload: Vec<u8>,
709    pub payload_sha256: String,
710}
711
712impl N4moptCheckpointArtifact {
713    #[cfg(feature = "methods-optimizer")]
714    fn new(
715        binding: HpoStudyBinding,
716        methods_abi: String,
717        opaque_payload: Vec<u8>,
718    ) -> HpoResult<Self> {
719        let value = Self {
720            schema_version: N4MOPT_CHECKPOINT_SCHEMA_VERSION,
721            artifact_kind: N4MOPT_ARTIFACT_KIND.to_string(),
722            format: N4MOPT_FORMAT.to_string(),
723            binding,
724            methods_abi,
725            payload_sha256: payload_sha256(&opaque_payload),
726            opaque_payload,
727        };
728        value.validate()?;
729        Ok(value)
730    }
731    pub fn validate(&self) -> HpoResult<()> {
732        if self.schema_version != N4MOPT_CHECKPOINT_SCHEMA_VERSION
733            || self.artifact_kind != N4MOPT_ARTIFACT_KIND
734            || self.format != N4MOPT_FORMAT
735        {
736            return Err(HpoError::InvalidCheckpoint {
737                reason: "checkpoint schema, kind, or format is invalid".to_string(),
738            });
739        }
740        self.binding.validate()?;
741        if self.methods_abi.trim().is_empty()
742            || self.opaque_payload.is_empty()
743            || self.opaque_payload.len() > MAX_N4MOPT_CHECKPOINT_BYTES
744        {
745            return Err(HpoError::InvalidCheckpoint {
746                reason: "checkpoint ABI/payload is invalid or exceeds the maximum size".to_string(),
747            });
748        }
749        if self.payload_sha256 != payload_sha256(&self.opaque_payload) {
750            return Err(HpoError::InvalidCheckpoint {
751                reason: "checkpoint payload SHA-256 differs from envelope".to_string(),
752            });
753        }
754        Ok(())
755    }
756}
757
758/// Durable archive-member reference for a Methods-owned N4MOPT payload.
759///
760/// The inline envelope is for a live study only. Training bundles persist this
761/// reference so native checkpoint bytes are a raw archive member rather than
762/// JSON-inline-only data owned by DAG-ML.
763#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
764#[serde(deny_unknown_fields)]
765pub struct N4moptCheckpointReference {
766    pub artifact: crate::runtime::ArtifactRef,
767    pub binding: HpoStudyBinding,
768    pub methods_abi: String,
769}
770
771impl N4moptCheckpointReference {
772    pub fn validate(&self) -> HpoResult<()> {
773        self.binding.validate()?;
774        if self.methods_abi.trim().is_empty() {
775            return Err(HpoError::InvalidCheckpoint {
776                reason: "checkpoint reference has no Methods ABI identity".to_string(),
777            });
778        }
779        self.artifact
780            .validate_portable()
781            .map_err(|error| HpoError::InvalidCheckpoint {
782                reason: format!("checkpoint archive reference is invalid: {error}"),
783            })?;
784        if self.artifact.kind != N4MOPT_ARTIFACT_KIND
785            || self.artifact.controller_id.as_str() != self.binding.controller_id
786            || self.artifact.backend != Some(crate::runtime::ArtifactBackend::Raw)
787        {
788            return Err(HpoError::InvalidCheckpoint {
789                reason: "checkpoint reference must be a raw Methods-owned N4MOPT artifact"
790                    .to_string(),
791            });
792        }
793        Ok(())
794    }
795}
796
797fn payload_sha256(payload: &[u8]) -> String {
798    format!("{:x}", Sha256::digest(payload))
799}
800
801/// A default-build preflight that fails before allocation, host data work, or
802/// any attempted replacement optimizer.
803pub fn methods_optimizer_preflight() -> HpoResult<()> {
804    #[cfg(feature = "methods-optimizer")]
805    {
806        Ok(())
807    }
808    #[cfg(not(feature = "methods-optimizer"))]
809    {
810        Err(HpoError::MethodsOptimizerFeatureDisabled)
811    }
812}
813
814/// Stable controller identity for the only portable numerical model admitted
815/// to the first Methods HPO route.  Other model classes stay host/plugin-owned
816/// and are rejected during HPO preflight rather than being silently evaluated
817/// by a fixture or a replacement implementation.
818pub const METHODS_PLS_CONTROLLER_ID: &str = "controller:methods.pls";
819
820/// Process-scoped binding to the exact Methods shared library used by native
821/// controllers. The official `n4m` binding refuses a second, different
822/// library, so a caller must configure this before constructing any Methods
823/// controller. Relative paths, PATH lookup, and a sibling/worktree fallback
824/// are deliberately not supported.
825#[cfg(feature = "methods-optimizer")]
826#[derive(Clone, Debug, Eq, PartialEq)]
827pub struct MethodsRuntime {
828    library_path: std::path::PathBuf,
829}
830
831#[cfg(feature = "methods-optimizer")]
832impl MethodsRuntime {
833    pub fn configure(library_path: impl AsRef<std::path::Path>) -> HpoResult<Self> {
834        let library_path = library_path.as_ref();
835        if !library_path.is_absolute() {
836            return Err(HpoError::RuntimeConfiguration {
837                reason: "libn4m path must be absolute".to_string(),
838            });
839        }
840        let canonical = std::fs::canonicalize(library_path).map_err(|error| {
841            HpoError::RuntimeConfiguration {
842                reason: format!(
843                    "cannot resolve libn4m path `{}`: {error}",
844                    library_path.display()
845                ),
846            }
847        })?;
848        let metadata =
849            std::fs::metadata(&canonical).map_err(|error| HpoError::RuntimeConfiguration {
850                reason: format!(
851                    "cannot inspect libn4m path `{}`: {error}",
852                    canonical.display()
853                ),
854            })?;
855        if !metadata.is_file() {
856            return Err(HpoError::RuntimeConfiguration {
857                reason: format!(
858                    "libn4m path `{}` is not a regular file",
859                    canonical.display()
860                ),
861            });
862        }
863        n4m::configure_library(&canonical).map_err(|error| HpoError::RuntimeConfiguration {
864            reason: format!("cannot load libn4m `{}`: {error}", canonical.display()),
865        })?;
866        Ok(Self {
867            library_path: canonical,
868        })
869    }
870
871    pub fn library_path(&self) -> &std::path::Path {
872        &self.library_path
873    }
874}
875
876/// Factory controller for the native optimizer.  It is deliberately distinct
877/// from the PLS model controller: only this registered tuner controller may
878/// create the thread-affine `MethodsHpoStudy` used by training.
879#[cfg(feature = "methods-optimizer")]
880pub struct MethodsHpoController {
881    id: crate::ControllerId,
882    _runtime: MethodsRuntime,
883}
884
885#[cfg(feature = "methods-optimizer")]
886impl MethodsHpoController {
887    pub fn new(id: crate::ControllerId, runtime: MethodsRuntime) -> Self {
888        Self {
889            id,
890            _runtime: runtime,
891        }
892    }
893}
894
895#[cfg(feature = "methods-optimizer")]
896impl crate::runtime::RuntimeController for MethodsHpoController {
897    fn controller_id(&self) -> &crate::ControllerId {
898        &self.id
899    }
900
901    fn invoke(&self, task: &crate::runtime::NodeTask) -> crate::Result<crate::runtime::NodeResult> {
902        Err(crate::DagMlError::RuntimeValidation(format!(
903            "Methods HPO controller `{}` is training-owned and cannot execute graph task `{}` directly",
904            self.id, task.node_plan.node_id
905        )))
906    }
907
908    fn create_tuner_session(
909        &self,
910        task: &crate::runtime::RuntimeHpoCampaignTask,
911        context: &crate::runtime::RuntimeHpoExecutionContext,
912    ) -> crate::Result<Box<dyn crate::runtime::RuntimeTunerSession>> {
913        if task.operation_id != context.operation_id
914            || task.controller_id != context.controller_id
915            || task.target_node_id != context.target_node_id
916            || context.study.controller_id != self.id.as_str()
917        {
918            return Err(crate::DagMlError::RuntimeValidation(
919                "Methods HPO tuner task/context identity mismatch".to_string(),
920            ));
921        }
922        let study = if let Some(checkpoint) = &context.resume_checkpoint {
923            MethodsHpoStudy::restore(context.study.clone(), checkpoint)
924        } else {
925            MethodsHpoStudy::create(context.study.clone())
926        }
927        .map_err(|error| {
928            crate::DagMlError::RuntimeValidation(format!(
929                "cannot create controller-owned native Methods HPO study: {error}"
930            ))
931        })?;
932        if context.resume_checkpoint.is_some() {
933            let mut native = study.trials().map_err(|error| {
934                crate::DagMlError::RuntimeValidation(format!(
935                    "cannot attest restored native Methods HPO ledger: {error}"
936                ))
937            })?;
938            native.sort_by_key(|trial| trial.id);
939            let native = canonical_hpo_terminal_ledger(native)?;
940            let persisted = canonical_hpo_terminal_ledger(
941                context
942                    .resume_terminal_trials
943                    .iter()
944                    .map(|snapshot| snapshot.trial.clone())
945                    .collect(),
946            )?;
947            if !hpo_terminal_trials_match(&native, &persisted) {
948                return Err(crate::DagMlError::RuntimeValidation(
949                    "restored native Methods HPO ledger does not exactly match persisted terminal evidence"
950                        .to_string(),
951                ));
952            }
953        }
954        Ok(Box::new(MethodsHpoSession {
955            study,
956            context: context.clone(),
957            controller_id: self.id.clone(),
958        }))
959    }
960}
961
962#[cfg(feature = "methods-optimizer")]
963struct MethodsHpoSession {
964    study: MethodsHpoStudy,
965    context: crate::runtime::RuntimeHpoExecutionContext,
966    controller_id: crate::ControllerId,
967}
968
969#[cfg(feature = "methods-optimizer")]
970impl crate::runtime::RuntimeTunerSession for MethodsHpoSession {
971    fn trial_history_len(&self) -> crate::Result<u32> {
972        let count = self
973            .study
974            .trials()
975            .map_err(|error| crate::DagMlError::RuntimeValidation(error.to_string()))?
976            .len();
977        u32::try_from(count).map_err(|_| {
978            crate::DagMlError::RuntimeValidation(
979                "native Methods HPO trial history exceeds u32 budget".to_string(),
980            )
981        })
982    }
983
984    fn ask(&mut self) -> crate::Result<Option<crate::runtime::RuntimeHpoProposal>> {
985        let trial = self
986            .study
987            .ask()
988            .map_err(|error| crate::DagMlError::RuntimeValidation(error.to_string()))?;
989        if self.context.study.search_space.parameters.len() != 1
990            || self.context.parameter_paths.len() != 1
991            || self.context.parameter_paths.get("n_components") != Some(&"n_components".to_string())
992            || !matches!(self.context.study.search_space.parameters.first(), Some(HpoParameter::Int { name, low: 1, high: 3, step: 1, log: false }) if name == "n_components")
993        {
994            return Err(crate::DagMlError::RuntimeValidation(
995                "Methods HPO v1 accepts only active integer n_components=1..3 mapped directly to the target model".to_string(),
996            ));
997        }
998        let parameter = trial.parameters.get("n_components").ok_or_else(|| {
999            crate::DagMlError::RuntimeValidation(
1000                "native Methods HPO trial omitted active n_components".to_string(),
1001            )
1002        })?;
1003        if !parameter.active
1004            || !parameter.integer
1005            || parameter.value.fract() != 0.0
1006            || !(1.0..=3.0).contains(&parameter.value)
1007        {
1008            return Err(crate::DagMlError::RuntimeValidation(
1009                "native Methods HPO emitted invalid n_components outside V1 integer bounds"
1010                    .to_string(),
1011            ));
1012        }
1013        let mut variant = self.context.base_variant.clone();
1014        variant.choices.insert(
1015            "native_methods_hpo".to_string(),
1016            crate::generation::GenerationChoice {
1017                label: format!("trial:{}", trial.id),
1018                value: serde_json::json!({"trial_id": trial.id}),
1019                param_overrides: vec![crate::generation::GenerationParamOverride {
1020                    node_id: self.context.target_node_id.clone(),
1021                    params: BTreeMap::from([(
1022                        "n_components".to_string(),
1023                        serde_json::json!(parameter.value as i64),
1024                    )]),
1025                }],
1026                active_subsequence: None,
1027            },
1028        );
1029        variant.variant_id = crate::VariantId::new(format!("hpo:trial:{}", trial.id))
1030            .map_err(|error| crate::DagMlError::RuntimeValidation(error.to_string()))?;
1031        variant.fingerprint = crate::campaign::stable_json_fingerprint(&(
1032            self.context.base_variant.fingerprint.as_str(),
1033            &variant.choices,
1034            trial.id,
1035        ))?;
1036        Ok(Some(crate::runtime::RuntimeHpoProposal {
1037            trial_id: trial.id,
1038            variant,
1039        }))
1040    }
1041
1042    fn report_intermediate(
1043        &mut self,
1044        value: crate::runtime::RuntimeHpoIntermediate,
1045    ) -> crate::Result<crate::runtime::RuntimeHpoIntermediateOutcome> {
1046        let pruned = self
1047            .study
1048            .report_intermediate(value.trial_id, value.step, value.score)
1049            .map_err(|error| crate::DagMlError::RuntimeValidation(error.to_string()))?;
1050        Ok(if pruned {
1051            crate::runtime::RuntimeHpoIntermediateOutcome::Pruned
1052        } else {
1053            crate::runtime::RuntimeHpoIntermediateOutcome::Continue
1054        })
1055    }
1056
1057    fn tell(
1058        &mut self,
1059        trial_id: i64,
1060        terminal: crate::runtime::RuntimeHpoTerminal,
1061    ) -> crate::Result<()> {
1062        let terminal = match terminal {
1063            crate::runtime::RuntimeHpoTerminal::Completed { score } => {
1064                HpoTerminal::Completed { score }
1065            }
1066            crate::runtime::RuntimeHpoTerminal::Failed { failure } => HpoTerminal::Failed {
1067                failure: HpoFailure {
1068                    code: failure.code,
1069                    message: failure.message,
1070                    retryable: failure.retryable,
1071                },
1072            },
1073        };
1074        self.study
1075            .tell(trial_id, terminal)
1076            .map_err(|error| crate::DagMlError::RuntimeValidation(error.to_string()))?;
1077        Ok(())
1078    }
1079
1080    fn checkpoint(&self) -> crate::Result<N4moptCheckpointArtifact> {
1081        let checkpoint = self.study.save_checkpoint().map_err(|error| {
1082            crate::DagMlError::RuntimeValidation(format!(
1083                "cannot save native Methods HPO checkpoint: {error}"
1084            ))
1085        })?;
1086        checkpoint.validate().map_err(|error| {
1087            crate::DagMlError::RuntimeValidation(format!(
1088                "invalid native Methods HPO checkpoint: {error}"
1089            ))
1090        })?;
1091        if checkpoint.binding.controller_id != self.controller_id.as_str()
1092            || checkpoint.binding.controller_id != self.context.study.controller_id
1093            || checkpoint.binding.study_id != self.context.study.study_id
1094            || checkpoint.methods_abi != self.context.study.methods_abi
1095        {
1096            return Err(crate::DagMlError::RuntimeValidation(
1097                "native Methods HPO checkpoint binding/ABI does not match its scheduler context"
1098                    .to_string(),
1099            ));
1100        }
1101        Ok(checkpoint)
1102    }
1103
1104    fn incumbent(
1105        &self,
1106        variants: &BTreeMap<i64, crate::VariantId>,
1107    ) -> crate::Result<Option<crate::runtime::RuntimeHpoIncumbent>> {
1108        let Some(best) = self.study.best().map_err(|error| {
1109            crate::DagMlError::RuntimeValidation(format!(
1110                "cannot read native Methods HPO incumbent: {error}"
1111            ))
1112        })?
1113        else {
1114            return Ok(None);
1115        };
1116        let score = if let Some(persisted) = self
1117            .context
1118            .resume_terminal_trials
1119            .iter()
1120            .find(|snapshot| snapshot.trial.id == best.trial.id)
1121        {
1122            let native = canonical_hpo_terminal_ledger(vec![best.trial.clone()])?;
1123            let prior = canonical_hpo_terminal_ledger(vec![persisted.trial.clone()])?;
1124            if !hpo_terminal_trials_match(&native, &prior) {
1125                return Err(crate::DagMlError::RuntimeValidation(
1126                    "native Methods HPO incumbent does not match persisted terminal evidence"
1127                        .to_string(),
1128                ));
1129            }
1130            persisted.trial.score.ok_or_else(|| {
1131                crate::DagMlError::RuntimeValidation(
1132                    "persisted Methods HPO incumbent has no terminal score".to_string(),
1133                )
1134            })?
1135        } else {
1136            best.score
1137        };
1138        let variant_id = variants.get(&best.trial.id).cloned().ok_or_else(|| {
1139            crate::DagMlError::RuntimeValidation(
1140                "native Methods HPO best() returned a trial without scheduler variant identity"
1141                    .to_string(),
1142            )
1143        })?;
1144        Ok(Some(crate::runtime::RuntimeHpoIncumbent {
1145            trial_id: best.trial.id,
1146            score,
1147            metric: self.context.selection.metric.name().to_string(),
1148            direction: self.context.selection.direction,
1149            variant_id,
1150        }))
1151    }
1152
1153    fn terminal_trial_snapshots(
1154        &self,
1155        variants: &BTreeMap<i64, crate::VariantId>,
1156    ) -> crate::Result<Vec<crate::runtime::RuntimeHpoTerminalSnapshot>> {
1157        let mut trials = self.study.trials().map_err(|error| {
1158            crate::DagMlError::RuntimeValidation(format!(
1159                "cannot read native Methods HPO terminal ledger: {error}"
1160            ))
1161        })?;
1162        trials.sort_by_key(|trial| trial.id);
1163        if trials.iter().any(|trial| {
1164            !matches!(
1165                trial.status,
1166                HpoTrialStatus::Completed | HpoTrialStatus::Pruned | HpoTrialStatus::Failed
1167            )
1168        }) {
1169            return Err(crate::DagMlError::RuntimeValidation(
1170                "native Methods HPO trial ledger contains a non-terminal trial".to_string(),
1171            ));
1172        }
1173        let persisted_by_id = self
1174            .context
1175            .resume_terminal_trials
1176            .iter()
1177            .map(|snapshot| (snapshot.trial.id, snapshot))
1178            .collect::<BTreeMap<_, _>>();
1179        trials
1180            .into_iter()
1181            .map(|trial| {
1182                if let Some(persisted) = persisted_by_id.get(&trial.id) {
1183                    let native = canonical_hpo_terminal_ledger(vec![trial])?;
1184                    let prior = canonical_hpo_terminal_ledger(vec![persisted.trial.clone()])?;
1185                    if !hpo_terminal_trials_match(&native, &prior) {
1186                        return Err(crate::DagMlError::RuntimeValidation(
1187                            "restored native Methods HPO trial does not match persisted terminal evidence"
1188                                .to_string(),
1189                        ));
1190                    }
1191                    return Ok((*persisted).clone());
1192                }
1193                Ok(crate::runtime::RuntimeHpoTerminalSnapshot {
1194                    variant_id: variants.get(&trial.id).cloned(),
1195                    trial,
1196                })
1197            })
1198            .collect()
1199    }
1200}
1201
1202#[cfg(feature = "methods-optimizer")]
1203mod pls_controller {
1204    use std::collections::{BTreeMap, BTreeSet};
1205    use std::sync::atomic::{AtomicU64, Ordering};
1206    use std::sync::Mutex;
1207
1208    use super::*;
1209    use crate::runtime::{
1210        ArtifactBackend, ArtifactRef, HandleKind, HandleRef, LineageRecord, MethodsPlsData,
1211        MethodsPlsDataRequest, NodeResult, NodeTask, PredictionBlock, PredictionPartition,
1212        RegressionTargetBlock, RuntimeController, RuntimeDataProvider,
1213    };
1214    use crate::{
1215        ArtifactId, ControllerId, DagMlError, LineageId, Phase, PredictionLevel, PredictionUnitId,
1216        Result,
1217    };
1218    use n4m::{Config, Context, MatrixRef, Model};
1219
1220    /// Execution-local native PLS controller.  It creates and drops `Context`,
1221    /// `Config`, and `Model` inside each invocation; the only retained state is
1222    /// exported N4MM bytes keyed by their durable artifact identity until the
1223    /// scheduler transfers them into the execution bundle.
1224    pub struct MethodsPlsController {
1225        id: ControllerId,
1226        _runtime: MethodsRuntime,
1227        next_handle: AtomicU64,
1228        /// Refit export is a one-shot transfer into the bundle.  It is never
1229        /// consulted by replay and is removed immediately by the scheduler.
1230        exported_n4mm_by_artifact: Mutex<BTreeMap<ArtifactId, Vec<u8>>>,
1231        /// Replay hydration creates fresh process-local handles from durable
1232        /// bundle bytes.  These entries are keyed by invocation-local handle,
1233        /// not an artifact id or a prior-controller handle map.
1234        hydrated_n4mm_by_handle: Mutex<BTreeMap<u64, Vec<u8>>>,
1235    }
1236
1237    impl MethodsPlsController {
1238        pub fn new(runtime: MethodsRuntime) -> Self {
1239            Self {
1240                id: ControllerId::new(METHODS_PLS_CONTROLLER_ID)
1241                    .expect("Methods PLS controller id is valid"),
1242                _runtime: runtime,
1243                next_handle: AtomicU64::new(0),
1244                exported_n4mm_by_artifact: Mutex::new(BTreeMap::new()),
1245                hydrated_n4mm_by_handle: Mutex::new(BTreeMap::new()),
1246            }
1247        }
1248
1249        /// Test-harness diagnostic for invocation-local payload ownership.
1250        #[doc(hidden)]
1251        pub fn hydrated_payload_count(&self) -> Result<usize> {
1252            self.hydrated_n4mm_by_handle
1253                .lock()
1254                .map(|payloads| payloads.len())
1255                .map_err(|_| {
1256                    DagMlError::RuntimeValidation(
1257                        "portable Methods PLS hydrated N4MM lock poisoned".to_string(),
1258                    )
1259                })
1260        }
1261
1262        fn handle(&self, kind: HandleKind) -> HandleRef {
1263            HandleRef {
1264                handle: self.next_handle.fetch_add(1, Ordering::SeqCst) + 1,
1265                kind,
1266                owner_controller: self.id.clone(),
1267            }
1268        }
1269
1270        fn request(
1271            task: &NodeTask,
1272            provider: &dyn RuntimeDataProvider,
1273        ) -> Result<MethodsPlsDataRequest> {
1274            let bindings = task
1275                .node_plan
1276                .data_bindings
1277                .iter()
1278                .filter(|binding| binding.input_name == "x")
1279                .collect::<Vec<_>>();
1280            let [binding] = bindings.as_slice() else {
1281                return Err(DagMlError::RuntimeValidation(format!(
1282                    "portable Methods PLS node `{}` requires exactly one `x` DataBinding",
1283                    task.node_plan.node_id
1284                )));
1285            };
1286            let identity = provider.training_data_identity(binding)?;
1287            if task.phase != Phase::Predict && identity.is_none() {
1288                return Err(DagMlError::RuntimeValidation(format!(
1289                    "portable Methods PLS provider did not attest target-bound DataBinding `{}.{}` for {:?}",
1290                    binding.node_id, binding.input_name, task.phase
1291                )));
1292            }
1293            let fit_view = task.data_views.get("x").or_else(|| task.data_views.get("data:x")).cloned().ok_or_else(|| {
1294                DagMlError::RuntimeValidation(format!(
1295                    "portable Methods PLS node `{}` requires its scheduler-created `x` data view (available: {:?})",
1296                    task.node_plan.node_id, task.data_views.keys().collect::<Vec<_>>()
1297                ))
1298            })?;
1299            let prediction_view = if task.phase == Phase::FitCv {
1300                Some(task.data_views.get("x:validation").or_else(|| task.data_views.get("data:x:validation")).cloned().ok_or_else(|| {
1301                    DagMlError::RuntimeValidation(format!(
1302                        "portable Methods PLS node `{}` requires its scheduler-created validation view",
1303                        task.node_plan.node_id
1304                    ))
1305                })?)
1306            } else {
1307                None
1308            };
1309            let request = MethodsPlsDataRequest {
1310                node_id: task.node_plan.node_id.clone(),
1311                phase: task.phase,
1312                variant_id: task.variant_id.clone(),
1313                fold_id: task.fold_id.clone(),
1314                binding: (*binding).clone(),
1315                identity,
1316                fit_view,
1317                prediction_view,
1318            };
1319            request.validate()?;
1320            Ok(request)
1321        }
1322
1323        fn components(task: &NodeTask) -> Result<i32> {
1324            let value = task.node_plan.params.get("n_components").ok_or_else(|| {
1325                DagMlError::RuntimeValidation(format!(
1326                    "portable Methods PLS node `{}` requires integer `n_components`",
1327                    task.node_plan.node_id
1328                ))
1329            })?;
1330            let value = value.as_i64().ok_or_else(|| {
1331                DagMlError::RuntimeValidation(
1332                    "portable Methods PLS `n_components` must be an integer".to_string(),
1333                )
1334            })?;
1335            i32::try_from(value)
1336                .ok()
1337                .filter(|value| *value > 0)
1338                .ok_or_else(|| {
1339                    DagMlError::RuntimeValidation(
1340                        "portable Methods PLS `n_components` must be a positive i32".to_string(),
1341                    )
1342                })
1343        }
1344
1345        fn native_error(operation: &str, error: n4m::Error) -> DagMlError {
1346            DagMlError::RuntimeValidation(format!(
1347                "portable Methods PLS {operation} failed: {error}"
1348            ))
1349        }
1350
1351        fn fit(task: &NodeTask, data: &MethodsPlsData) -> Result<(Context, Model)> {
1352            let context =
1353                Context::new().map_err(|error| Self::native_error("context_create", error))?;
1354            let mut config =
1355                Config::new().map_err(|error| Self::native_error("config_create", error))?;
1356            config
1357                .set_n_components(Self::components(task)?)
1358                .map_err(|error| Self::native_error("config_set_n_components", error))?;
1359            let x = MatrixRef::row_major(&data.fit.x.values, data.fit.x.rows, data.fit.x.cols)
1360                .map_err(|error| Self::native_error("fit_x_matrix", error))?;
1361            let targets = data.fit.y.as_ref().ok_or_else(|| {
1362                DagMlError::RuntimeValidation(
1363                    "portable Methods PLS fit requires targets".to_string(),
1364                )
1365            })?;
1366            let y = MatrixRef::row_major(&targets.values, targets.rows, targets.cols)
1367                .map_err(|error| Self::native_error("fit_y_matrix", error))?;
1368            let model = Model::fit(&context, &config, x, y)
1369                .map_err(|error| Self::native_error("fit", error))?;
1370            Ok((context, model))
1371        }
1372
1373        fn predict(
1374            context: &Context,
1375            model: &Model,
1376            data: &crate::runtime::MethodsPlsDataset,
1377        ) -> Result<Vec<Vec<f64>>> {
1378            let x = MatrixRef::row_major(&data.x.values, data.x.rows, data.x.cols)
1379                .map_err(|error| Self::native_error("predict_x_matrix", error))?;
1380            let prediction = model
1381                .predict(context, x)
1382                .map_err(|error| Self::native_error("predict", error))?;
1383            Ok(prediction
1384                .data
1385                .chunks(prediction.cols)
1386                .map(|row| row.to_vec())
1387                .collect())
1388        }
1389
1390        fn result(
1391            &self,
1392            task: &NodeTask,
1393            dataset: &crate::runtime::MethodsPlsDataset,
1394            values: Vec<Vec<f64>>,
1395            artifact: Option<(ArtifactRef, HandleRef)>,
1396        ) -> Result<NodeResult> {
1397            let partition = if task.phase == Phase::FitCv {
1398                PredictionPartition::Validation
1399            } else {
1400                PredictionPartition::Final
1401            };
1402            let prediction = PredictionBlock {
1403                prediction_id: Some(format!(
1404                    "methods-pls:{}:{}:{}",
1405                    task.node_plan.node_id,
1406                    task.phase.as_str(),
1407                    task.fold_id
1408                        .as_ref()
1409                        .map(|id| id.as_str())
1410                        .unwrap_or("full")
1411                )),
1412                producer_node: task.node_plan.node_id.clone(),
1413                producer_port: Some("oof".to_string()),
1414                partition,
1415                fold_id: (task.phase == Phase::FitCv)
1416                    .then(|| task.fold_id.clone())
1417                    .flatten(),
1418                sample_ids: dataset.sample_ids.clone(),
1419                values,
1420                target_names: dataset.target_names.clone(),
1421            };
1422            let regression_targets = if task.phase == Phase::FitCv {
1423                let targets = dataset.y.as_ref().ok_or_else(|| {
1424                    DagMlError::RuntimeValidation(
1425                        "portable Methods PLS FIT_CV requires validation targets".to_string(),
1426                    )
1427                })?;
1428                vec![RegressionTargetBlock {
1429                    level: PredictionLevel::Sample,
1430                    unit_ids: dataset
1431                        .sample_ids
1432                        .iter()
1433                        .cloned()
1434                        .map(PredictionUnitId::Sample)
1435                        .collect(),
1436                    values: targets
1437                        .values
1438                        .chunks(targets.cols)
1439                        .map(|row| row.to_vec())
1440                        .collect(),
1441                    target_names: dataset.target_names.clone(),
1442                }]
1443            } else {
1444                Vec::new()
1445            };
1446            let (artifacts, artifact_handles) = artifact
1447                .map(|(artifact, handle)| {
1448                    (
1449                        vec![artifact.clone()],
1450                        BTreeMap::from([(artifact.id, handle)]),
1451                    )
1452                })
1453                .unwrap_or_default();
1454            let artifact_refs = artifacts.clone();
1455            Ok(NodeResult {
1456                schema_version: None,
1457                node_id: task.node_plan.node_id.clone(),
1458                outputs: BTreeMap::from([("oof".to_string(), self.handle(HandleKind::Prediction))]),
1459                predictions: vec![prediction],
1460                observation_predictions: Vec::new(),
1461                aggregated_predictions: Vec::new(),
1462                explanations: Vec::new(),
1463                shape_deltas: Vec::new(),
1464                artifacts,
1465                artifact_handles,
1466                fit_influence_diagnostics: Vec::new(),
1467                regression_targets,
1468                lineage: LineageRecord {
1469                    record_id: LineageId::new(format!(
1470                        "lineage:methods-pls:{}:{}:{}:{}",
1471                        task.node_plan.node_id,
1472                        task.phase.as_str(),
1473                        task.variant_id
1474                            .as_ref()
1475                            .map(|id| id.as_str())
1476                            .unwrap_or("base"),
1477                        task.fold_id
1478                            .as_ref()
1479                            .map(|id| id.as_str())
1480                            .unwrap_or("full")
1481                    ))
1482                    .expect("valid native PLS lineage id"),
1483                    run_id: task.run_id.clone(),
1484                    node_id: task.node_plan.node_id.clone(),
1485                    phase: task.phase,
1486                    controller_id: self.id.clone(),
1487                    controller_version: task.node_plan.controller_version.clone(),
1488                    variant_id: task.variant_id.clone(),
1489                    fold_id: task.fold_id.clone(),
1490                    branch_path: task.branch_path.clone(),
1491                    input_lineage: Vec::new(),
1492                    artifact_refs,
1493                    params_fingerprint: task.node_plan.params_fingerprint.clone(),
1494                    data_model_shape_fingerprint: None,
1495                    aggregation_policy_fingerprint: None,
1496                    seed: task.seed,
1497                    unsafe_flags: BTreeSet::new(),
1498                    metrics: BTreeMap::new(),
1499                    loss_attestations: Vec::new(),
1500                    early_stopping_records: Vec::new(),
1501                },
1502            })
1503        }
1504    }
1505
1506    impl RuntimeController for MethodsPlsController {
1507        fn controller_id(&self) -> &ControllerId {
1508            &self.id
1509        }
1510
1511        fn export_artifact_payload(&self, artifact_id: &ArtifactId) -> Result<Option<Vec<u8>>> {
1512            Ok(self
1513                .exported_n4mm_by_artifact
1514                .lock()
1515                .map_err(|_| {
1516                    DagMlError::RuntimeValidation(
1517                        "portable Methods PLS N4MM sidecar lock poisoned".to_string(),
1518                    )
1519                })?
1520                .remove(artifact_id))
1521        }
1522
1523        fn hydrate_artifact_payload(
1524            &self,
1525            request: &crate::runtime::ArtifactMaterializationRequest,
1526            payload: &[u8],
1527        ) -> Result<HandleRef> {
1528            if request.artifact.kind != "n4m_model"
1529                || request.artifact.backend != Some(ArtifactBackend::Raw)
1530            {
1531                return Err(DagMlError::RuntimeValidation(format!(
1532                    "portable Methods PLS cannot hydrate non-N4MM artifact `{}`",
1533                    request.artifact.id
1534                )));
1535            }
1536            if format!("{:x}", Sha256::digest(payload))
1537                != request
1538                    .artifact
1539                    .content_fingerprint
1540                    .as_deref()
1541                    .unwrap_or_default()
1542                || request.artifact.size_bytes != Some(payload.len() as u64)
1543            {
1544                return Err(DagMlError::RuntimeValidation(format!(
1545                    "portable Methods PLS payload `{}` does not match its artifact reference",
1546                    request.artifact.id
1547                )));
1548            }
1549            // Import once at hydration time to reject corrupt bytes before any
1550            // task executes. Prediction imports again into its task-local
1551            // native context, which avoids retaining native model handles.
1552            let context = Context::new()
1553                .map_err(|error| Self::native_error("hydrate_context_create", error))?;
1554            Model::import_n4mm(&context, payload)
1555                .map_err(|error| Self::native_error("hydrate_import_n4mm", error))?;
1556            let handle = self.handle(HandleKind::Model);
1557            self.hydrated_n4mm_by_handle
1558                .lock()
1559                .map_err(|_| {
1560                    DagMlError::RuntimeValidation(
1561                        "portable Methods PLS hydrated N4MM lock poisoned".to_string(),
1562                    )
1563                })?
1564                .insert(handle.handle, payload.to_vec());
1565            Ok(handle)
1566        }
1567
1568        fn release_hydrated_artifact_payload(&self, handle: &HandleRef) -> Result<()> {
1569            if handle.kind != HandleKind::Model || handle.owner_controller != self.id {
1570                return Err(DagMlError::RuntimeValidation(format!(
1571                    "portable Methods PLS cannot release foreign hydrated handle {}",
1572                    handle.handle
1573                )));
1574            }
1575            // Successful PREDICT consumes the entry itself. Replay rollback
1576            // reaches this same hook before invocation or after an error, so
1577            // absence is deliberately idempotent rather than an ownership
1578            // failure.
1579            self.hydrated_n4mm_by_handle
1580                .lock()
1581                .map_err(|_| {
1582                    DagMlError::RuntimeValidation(
1583                        "portable Methods PLS hydrated N4MM lock poisoned".to_string(),
1584                    )
1585                })?
1586                .remove(&handle.handle);
1587            Ok(())
1588        }
1589
1590        fn invoke(&self, task: &NodeTask) -> Result<NodeResult> {
1591            Err(DagMlError::RuntimeValidation(format!(
1592                "portable Methods PLS node `{}` requires a RuntimeDataProvider numeric view",
1593                task.node_plan.node_id
1594            )))
1595        }
1596
1597        fn invoke_with_data_provider(
1598            &self,
1599            task: &NodeTask,
1600            provider: &dyn RuntimeDataProvider,
1601        ) -> Result<NodeResult> {
1602            if task.node_plan.kind != crate::graph::NodeKind::Model {
1603                return Err(DagMlError::RuntimeValidation(
1604                    "portable Methods PLS controller only serves model nodes".to_string(),
1605                ));
1606            }
1607            let request = Self::request(task, provider)?;
1608            provider.preflight_methods_pls(&request)?;
1609            let data = provider.methods_pls_data(&request)?;
1610            data.validate_for(&request)?;
1611            match task.phase {
1612                Phase::FitCv | Phase::Refit => {
1613                    let (context, model) = Self::fit(task, &data)?;
1614                    let prediction_data = data.prediction.as_ref().unwrap_or(&data.fit);
1615                    let values = Self::predict(&context, &model, prediction_data)?;
1616                    let artifact = if task.phase == Phase::Refit {
1617                        let bytes = model
1618                            .export_n4mm()
1619                            .map_err(|error| Self::native_error("export_n4mm", error))?;
1620                        let handle = self.handle(HandleKind::Model);
1621                        let fingerprint = format!("{:x}", Sha256::digest(&bytes));
1622                        let id = ArtifactId::new(format!(
1623                            "artifact:methods-pls:{}:refit",
1624                            task.node_plan.node_id
1625                        ))
1626                        .map_err(|error| DagMlError::RuntimeValidation(error.to_string()))?;
1627                        self.exported_n4mm_by_artifact
1628                            .lock()
1629                            .map_err(|_| {
1630                                DagMlError::RuntimeValidation(
1631                                    "portable Methods PLS N4MM sidecar lock poisoned".to_string(),
1632                                )
1633                            })?
1634                            .insert(id.clone(), bytes.clone());
1635                        Some((
1636                            ArtifactRef {
1637                                id,
1638                                kind: "n4m_model".to_string(),
1639                                controller_id: self.id.clone(),
1640                                backend: Some(ArtifactBackend::Raw),
1641                                // Archive V2 P0 has a closed native Methods namespace.  This
1642                                // URI is part of the signed portable package, so emitting the
1643                                // final archive member path here prevents a writer from
1644                                // translating or duplicating an artifact reference later.
1645                                uri: Some(format!(
1646                                    "methods/{}.n4mm",
1647                                    task.node_plan.node_id.as_str().replace(':', "_")
1648                                )),
1649                                content_fingerprint: Some(fingerprint),
1650                                size_bytes: Some(bytes.len() as u64),
1651                                plugin: None,
1652                                plugin_version: None,
1653                            },
1654                            handle,
1655                        ))
1656                    } else {
1657                        None
1658                    };
1659                    self.result(task, prediction_data, values, artifact)
1660                }
1661                Phase::Predict => {
1662                    let artifact = task.artifact_inputs.values().find(|artifact| artifact.controller_id == self.id).ok_or_else(|| DagMlError::RuntimeValidation("portable Methods PLS PREDICT requires its retained N4MM artifact reference".to_string()))?;
1663                    let handle = task
1664                        .input_handles
1665                        .get(&crate::runtime::refit_artifact_input_key(&artifact.artifact.id))
1666                        .ok_or_else(|| {
1667                            DagMlError::RuntimeValidation(
1668                                "portable Methods PLS PREDICT requires a hydrated N4MM runtime handle"
1669                                    .to_string(),
1670                            )
1671                        })?;
1672                    // This is an invocation-local, one-shot capability.  Do
1673                    // not retain bundle bytes in a long-lived controller map
1674                    // after the prediction consuming them has completed.
1675                    let bytes = self.hydrated_n4mm_by_handle.lock().map_err(|_| DagMlError::RuntimeValidation("portable Methods PLS hydrated N4MM lock poisoned".to_string()))?.remove(&handle.handle).ok_or_else(|| DagMlError::RuntimeValidation("portable Methods PLS PREDICT requires N4MM bytes hydrated from the execution bundle in this controller instance".to_string()))?;
1676                    let context = Context::new()
1677                        .map_err(|error| Self::native_error("context_create", error))?;
1678                    let model = Model::import_n4mm(&context, &bytes)
1679                        .map_err(|error| Self::native_error("import_n4mm", error))?;
1680                    let values = Self::predict(&context, &model, &data.fit)?;
1681                    self.result(task, &data.fit, values, None)
1682                }
1683                _ => Err(DagMlError::RuntimeValidation(
1684                    "portable Methods PLS supports FIT_CV, REFIT, and PREDICT only".to_string(),
1685                )),
1686            }
1687        }
1688    }
1689}
1690
1691#[cfg(feature = "methods-optimizer")]
1692pub use pls_controller::MethodsPlsController;
1693
1694/// Register the complete native Methods controller pair for one process.
1695///
1696/// The caller supplies the already-configured runtime and the controller id
1697/// attested by its native HPO campaign.  Registration is preflighted before
1698/// mutating the registry, so a duplicate id cannot leave a half-registered
1699/// Methods runtime behind.  No study, model, or artifact handle is created by
1700/// this operation.
1701#[cfg(feature = "methods-optimizer")]
1702pub fn register_methods_runtime_controllers(
1703    registry: &mut crate::runtime::RuntimeControllerRegistry,
1704    hpo_controller_id: crate::ControllerId,
1705    runtime: MethodsRuntime,
1706) -> crate::Result<()> {
1707    let pls_controller_id = crate::ControllerId::new(METHODS_PLS_CONTROLLER_ID)
1708        .expect("the fixed Methods PLS controller id is valid");
1709    if hpo_controller_id == pls_controller_id {
1710        return Err(crate::DagMlError::RuntimeValidation(
1711            "Methods HPO controller id must differ from the Methods PLS controller id".to_string(),
1712        ));
1713    }
1714    for controller_id in [&pls_controller_id, &hpo_controller_id] {
1715        if registry.get(controller_id).is_some() {
1716            return Err(crate::DagMlError::RuntimeValidation(format!(
1717                "duplicate runtime controller `{controller_id}`"
1718            )));
1719        }
1720    }
1721    registry.register(Box::new(MethodsPlsController::new(runtime.clone())))?;
1722    registry.register(Box::new(MethodsHpoController::new(
1723        hpo_controller_id,
1724        runtime,
1725    )))?;
1726    Ok(())
1727}
1728
1729#[cfg(feature = "methods-optimizer")]
1730mod native {
1731    use super::*;
1732    use n4m::{
1733        Category, Direction, Error, ErrorKind, Optimizer, OptimizerOptions, Pruner, Sampler,
1734        SearchSpace, TrialError, TrialSnapshot, TrialStatus,
1735    };
1736
1737    pub(super) struct MethodsHpoStudy {
1738        manifest: MethodsHpoControllerManifest,
1739        methods_abi: String,
1740        _context: n4m::Context,
1741        optimizer: Optimizer,
1742        events: Vec<HpoEvent>,
1743    }
1744
1745    // This adapter preserves the direct reporter lifecycle for the official
1746    // binding's focused native tests; production scheduler operation uses the
1747    // explicit RuntimeTunerSession bridge below instead.
1748    #[allow(dead_code)]
1749    struct MethodsHpoReporter<'a> {
1750        study: &'a mut MethodsHpoStudy,
1751        trial_id: i64,
1752        pruned: Option<HpoTrial>,
1753    }
1754
1755    impl HpoIntermediateReporter for MethodsHpoReporter<'_> {
1756        fn report(&mut self, step: i32, score: f64) -> HpoResult<HpoReportOutcome> {
1757            if self.pruned.is_some() {
1758                return Err(HpoError::InvalidTrial {
1759                    reason: "cannot report after native pruning terminalized the trial".to_string(),
1760                });
1761            }
1762            if self.study.report_intermediate(self.trial_id, step, score)? {
1763                let snapshot = self.study.snapshot_for(self.trial_id)?;
1764                if snapshot.status != HpoTrialStatus::Pruned {
1765                    return Err(HpoError::InvalidTrial {
1766                        reason: "native pruner returned true without a PRUNED snapshot".to_string(),
1767                    });
1768                }
1769                self.pruned = Some(snapshot.clone());
1770                return Ok(HpoReportOutcome::Pruned(snapshot));
1771            }
1772            Ok(HpoReportOutcome::Continue)
1773        }
1774    }
1775
1776    impl MethodsHpoStudy {
1777        pub(super) fn create(config: MethodsHpoStudyConfig) -> HpoResult<Self> {
1778            methods_optimizer_preflight()?;
1779            let methods_abi = config.methods_abi_identity()?;
1780            let fingerprint = config.search_space.fingerprint()?;
1781            let optimizer_fingerprint = config.optimizer.fingerprint()?;
1782            let manifest = MethodsHpoControllerManifest {
1783                schema_version: HPO_MANIFEST_SCHEMA_VERSION,
1784                binding: HpoStudyBinding {
1785                    controller_id: config.controller_id,
1786                    study_id: config.study_id,
1787                    search_space_fingerprint: fingerprint,
1788                    optimizer_fingerprint,
1789                },
1790            };
1791            manifest.validate()?;
1792            let context =
1793                n4m::Context::new().map_err(|error| native_error("context_create", error))?;
1794            let native_space = create_space(&config.search_space)?;
1795            let options = create_options(&config.optimizer);
1796            let optimizer = Optimizer::new(&context, &native_space, &options)
1797                .map_err(|error| native_error("optimizer_create", error))?;
1798            Ok(Self {
1799                manifest,
1800                methods_abi,
1801                _context: context,
1802                optimizer,
1803                events: Vec::new(),
1804            })
1805        }
1806        pub(super) fn restore(
1807            config: MethodsHpoStudyConfig,
1808            checkpoint: &N4moptCheckpointArtifact,
1809        ) -> HpoResult<Self> {
1810            methods_optimizer_preflight()?;
1811            let methods_abi = config.methods_abi_identity()?;
1812            let fingerprint = config.search_space.fingerprint()?;
1813            let optimizer_fingerprint = config.optimizer.fingerprint()?;
1814            let binding = HpoStudyBinding {
1815                controller_id: config.controller_id,
1816                study_id: config.study_id,
1817                search_space_fingerprint: fingerprint,
1818                optimizer_fingerprint,
1819            };
1820            checkpoint.validate()?;
1821            if checkpoint.binding != binding || checkpoint.methods_abi != methods_abi {
1822                return Err(HpoError::CheckpointBindingMismatch {
1823                    reason: "study/search-space or Methods ABI differs from checkpoint".to_string(),
1824                });
1825            }
1826            // The official binding performs the N4MOPT envelope preflight and
1827            // native decoder is the final validator; no local decoder exists.
1828            let context =
1829                n4m::Context::new().map_err(|error| native_error("context_create", error))?;
1830            let optimizer = Optimizer::load_n4mopt(&context, &checkpoint.opaque_payload)
1831                .map_err(|error| native_error("load_n4mopt", error))?;
1832            Ok(Self {
1833                manifest: MethodsHpoControllerManifest {
1834                    schema_version: HPO_MANIFEST_SCHEMA_VERSION,
1835                    binding,
1836                },
1837                methods_abi,
1838                _context: context,
1839                optimizer,
1840                events: Vec::new(),
1841            })
1842        }
1843        #[allow(dead_code)]
1844        pub fn manifest(&self) -> &MethodsHpoControllerManifest {
1845            &self.manifest
1846        }
1847        #[allow(dead_code)]
1848        pub fn events(&self) -> &[HpoEvent] {
1849            &self.events
1850        }
1851        pub fn ask(&mut self) -> HpoResult<HpoTrial> {
1852            let id = self
1853                .optimizer
1854                .ask()
1855                .and_then(|trial| trial.id())
1856                .map_err(|error| native_error("ask", error))?;
1857            let trial = self.snapshot_for(id)?;
1858            self.events.push(HpoEvent::Asked { trial_id: trial.id });
1859            Ok(trial)
1860        }
1861        #[allow(dead_code)]
1862        pub fn ask_batch(&mut self, count: i32) -> HpoResult<HpoBatch> {
1863            match self.optimizer.ask_batch(count) {
1864                Ok(native_trials) => {
1865                    let ids = native_trials
1866                        .iter()
1867                        .map(|trial| {
1868                            trial
1869                                .id()
1870                                .map_err(|error| native_error("trial_get_id", error))
1871                        })
1872                        .collect::<HpoResult<Vec<_>>>()?;
1873                    let trials = ids
1874                        .into_iter()
1875                        .map(|id| self.snapshot_for(id))
1876                        .collect::<HpoResult<Vec<_>>>()?;
1877                    for trial in &trials {
1878                        self.events.push(HpoEvent::Asked { trial_id: trial.id });
1879                    }
1880                    Ok(HpoBatch {
1881                        trials,
1882                        native_error: None,
1883                    })
1884                }
1885                Err(n4m::AskBatchError::Partial {
1886                    error,
1887                    trials: native_trials,
1888                }) => {
1889                    let ids = native_trials
1890                        .iter()
1891                        .map(|trial| {
1892                            trial
1893                                .id()
1894                                .map_err(|error| native_error("trial_get_id", error))
1895                        })
1896                        .collect::<HpoResult<Vec<_>>>()?;
1897                    let trials = ids
1898                        .into_iter()
1899                        .map(|id| self.snapshot_for(id))
1900                        .collect::<HpoResult<Vec<_>>>()?;
1901                    for trial in &trials {
1902                        self.events.push(HpoEvent::Asked { trial_id: trial.id });
1903                    }
1904                    Ok(HpoBatch {
1905                        trials,
1906                        native_error: Some(to_native_error(error)),
1907                    })
1908                }
1909                Err(n4m::AskBatchError::Error(error)) => Err(native_error("ask_batch", error)),
1910            }
1911        }
1912        pub fn report_intermediate(
1913            &mut self,
1914            trial_id: i64,
1915            step: i32,
1916            score: f64,
1917        ) -> HpoResult<bool> {
1918            if !score.is_finite() {
1919                return Err(HpoError::InvalidTrial {
1920                    reason: "intermediate score must be finite".to_string(),
1921                });
1922            }
1923            let should_prune = self
1924                .optimizer
1925                .tell_intermediate(trial_id, step, score)
1926                .map_err(|error| native_error("tell_intermediate", error))?;
1927            self.events.push(HpoEvent::Intermediate {
1928                trial_id,
1929                step,
1930                score,
1931                should_prune,
1932            });
1933            // libn4m terminalizes a pruned trial as part of the intermediate
1934            // operation. Calling tell_result(PRUNED) afterwards is invalid.
1935            if should_prune {
1936                self.events.push(HpoEvent::Terminal {
1937                    trial_id,
1938                    status: HpoTrialStatus::Pruned,
1939                    score: None,
1940                    failure: None,
1941                });
1942            }
1943            Ok(should_prune)
1944        }
1945        /// Terminalize natively and return the post-`tell` native snapshot.
1946        /// Returning the pre-tell `RUNNING` proposal would make persistence and
1947        /// replay lose the terminal sequence/error selected by Methods.
1948        pub fn tell(&mut self, trial_id: i64, terminal: HpoTerminal) -> HpoResult<HpoTrial> {
1949            let (status, score, failure) = match terminal {
1950                HpoTerminal::Completed { score } if score.is_finite() => {
1951                    (TrialStatus::Completed, score, None)
1952                }
1953                HpoTerminal::Completed { .. } => {
1954                    return Err(HpoError::InvalidTrial {
1955                        reason: "terminal score must be finite".to_string(),
1956                    })
1957                }
1958                HpoTerminal::Failed { failure } => (TrialStatus::Failed, 0.0, Some(failure)),
1959                // Native pruning has already terminalized at intermediate
1960                // reporting time and deliberately rejects a TrialError here.
1961                HpoTerminal::Pruned { failure } => (TrialStatus::Pruned, 0.0, Some(failure)),
1962                HpoTerminal::Cancelled { failure } => (TrialStatus::Cancelled, 0.0, Some(failure)),
1963            };
1964            let native_failure = matches!(status, TrialStatus::Failed | TrialStatus::Cancelled)
1965                .then_some(failure.as_ref())
1966                .flatten()
1967                .map(|failure| TrialError {
1968                    code: failure.code.clone(),
1969                    message: failure.message.clone(),
1970                    retryable: failure.retryable,
1971                });
1972            self.optimizer
1973                .tell_result(trial_id, status, score, native_failure.as_ref())
1974                .map_err(|error| native_error("tell_result", error))?;
1975            let snapshot = self.snapshot_for(trial_id)?;
1976            self.events.push(HpoEvent::Terminal {
1977                trial_id,
1978                status: snapshot.status,
1979                score: snapshot.score,
1980                failure: snapshot.failure.clone(),
1981            });
1982            Ok(snapshot)
1983        }
1984        #[allow(dead_code)]
1985        pub fn evaluate_one<E: HpoEvaluator>(
1986            &mut self,
1987            evaluator: &mut E,
1988            boundary: &mut HpoEvaluationBoundary<'_>,
1989        ) -> HpoResult<HpoTrial> {
1990            boundary.validate()?;
1991            let trial = self.ask()?;
1992            let (terminal, pruned) = {
1993                let mut reporter = MethodsHpoReporter {
1994                    study: self,
1995                    trial_id: trial.id,
1996                    pruned: None,
1997                };
1998                let terminal = evaluator.evaluate_with_reporter(&trial, boundary, &mut reporter);
1999                (terminal, reporter.pruned.take())
2000            };
2001            let terminal = match terminal {
2002                Ok(terminal) => terminal,
2003                Err(error) => {
2004                    if pruned.is_some() {
2005                        // The reporter's native intermediate call already
2006                        // terminalized the trial; never issue a second tell.
2007                        return Err(error);
2008                    }
2009                    let failure = HpoFailure {
2010                        code: "HPO_EVALUATION".to_string(),
2011                        message: error.to_string(),
2012                        retryable: true,
2013                    };
2014                    // Do not strand a native RUNNING trial when the DAG-ML
2015                    // evaluator itself fails. Preserve the original error.
2016                    let _ = self.tell(trial.id, HpoTerminal::Failed { failure });
2017                    return Err(error);
2018                }
2019            };
2020            if let Some(snapshot) = pruned {
2021                // Pruning is terminalized by tell_intermediate. The evaluator
2022                // must not turn that native decision into a later tell result.
2023                return Ok(snapshot);
2024            }
2025            self.tell(trial.id, terminal)
2026        }
2027        pub fn best(&self) -> HpoResult<Option<HpoBestTrial>> {
2028            let Some((trial, score)) = self
2029                .optimizer
2030                .best()
2031                .map_err(|error| native_error("best", error))?
2032            else {
2033                return Ok(None);
2034            };
2035            let id = trial
2036                .id()
2037                .map_err(|error| native_error("trial_get_id", error))?;
2038            self.snapshot_for(id)
2039                .map(|trial| Some(HpoBestTrial { trial, score }))
2040        }
2041        pub fn trials(&self) -> HpoResult<Vec<HpoTrial>> {
2042            self.optimizer
2043                .trials(0)
2044                .map_err(|error| native_error("trials", error))?
2045                .iter()
2046                .map(snapshot_trial)
2047                .collect()
2048        }
2049        pub fn save_checkpoint(&self) -> HpoResult<N4moptCheckpointArtifact> {
2050            let payload = self
2051                .optimizer
2052                .save_n4mopt()
2053                .map_err(|error| native_error("save_n4mopt", error))?;
2054            if payload.len() > MAX_N4MOPT_CHECKPOINT_BYTES {
2055                return Err(HpoError::InvalidCheckpoint {
2056                    reason: "native checkpoint exceeds configured limit".to_string(),
2057                });
2058            }
2059            N4moptCheckpointArtifact::new(
2060                self.manifest.binding.clone(),
2061                self.methods_abi.clone(),
2062                payload,
2063            )
2064        }
2065        fn snapshot_for(&self, id: i64) -> HpoResult<HpoTrial> {
2066            self.optimizer
2067                .trials(id)
2068                .map_err(|error| native_error("trials", error))?
2069                .into_iter()
2070                .find(|trial| trial.id == id)
2071                .ok_or_else(|| HpoError::InvalidTrial {
2072                    reason: format!("native trial history omitted committed trial `{id}`"),
2073                })
2074                .and_then(|trial| snapshot_trial(&trial))
2075        }
2076    }
2077
2078    fn create_space(space: &HpoSearchSpace) -> HpoResult<SearchSpace> {
2079        let mut result =
2080            SearchSpace::new().map_err(|error| native_error("search_space_create", error))?;
2081        for parameter in &space.parameters {
2082            let call = match parameter {
2083                HpoParameter::Int {
2084                    name,
2085                    low,
2086                    high,
2087                    step,
2088                    log,
2089                } => result.add_int(name, *low, *high, *step, *log),
2090                HpoParameter::Float {
2091                    name,
2092                    low,
2093                    high,
2094                    step,
2095                    log,
2096                } => result.add_float(name, *low, *high, *step, *log),
2097                HpoParameter::Categorical { name, values } => result
2098                    .add_categorical(name, &values.iter().map(map_category).collect::<Vec<_>>()),
2099                HpoParameter::Ordinal { name, values } => result.add_ordinal(name, values),
2100                HpoParameter::SortedTuple {
2101                    name,
2102                    length,
2103                    low,
2104                    high,
2105                    integer,
2106                } => result.add_sorted_tuple(name, *length, *low, *high, *integer),
2107            };
2108            call.map_err(|error| native_error("search_space_add", error))?;
2109        }
2110        Ok(result)
2111    }
2112    fn map_category(value: &HpoCategory) -> Category {
2113        match value {
2114            HpoCategory::String(value) => Category::Str(value.clone()),
2115            HpoCategory::Integer(value) => Category::Int(*value),
2116            HpoCategory::Float(value) => Category::Float(*value),
2117            HpoCategory::Boolean(value) => Category::Bool(*value),
2118        }
2119    }
2120    fn create_options(config: &HpoOptimizerConfig) -> OptimizerOptions {
2121        OptimizerOptions {
2122            sampler: match config.sampler {
2123                HpoSampler::Random => Sampler::Random,
2124                HpoSampler::Sobol => Sampler::Sobol,
2125                HpoSampler::Lhs => Sampler::Lhs,
2126                HpoSampler::Ternary => Sampler::Ternary,
2127                HpoSampler::Ga => Sampler::Ga,
2128                HpoSampler::Pso => Sampler::Pso,
2129                HpoSampler::Cmaes => Sampler::Cmaes,
2130                HpoSampler::Tpe => Sampler::Tpe,
2131                HpoSampler::GpEi => Sampler::GpEi,
2132            },
2133            pruner: match config.pruner {
2134                HpoPruner::None => Pruner::None,
2135                HpoPruner::Median => Pruner::Median,
2136                HpoPruner::Asha => Pruner::Asha,
2137                HpoPruner::Hyperband => Pruner::Hyperband,
2138                HpoPruner::Racing => Pruner::Racing,
2139            },
2140            direction: match config.direction {
2141                HpoDirection::Auto => Direction::Auto,
2142                HpoDirection::Minimize => Direction::Minimize,
2143                HpoDirection::Maximize => Direction::Maximize,
2144            },
2145            metric: match config.metric {
2146                HpoMetric::Rmse => n4m::Metric::Rmse,
2147                HpoMetric::Mse => n4m::Metric::Mse,
2148                HpoMetric::Mae => n4m::Metric::Mae,
2149                HpoMetric::R2 => n4m::Metric::R2,
2150                HpoMetric::Accuracy => n4m::Metric::Accuracy,
2151                HpoMetric::BalancedAccuracy => n4m::Metric::BalancedAccuracy,
2152                HpoMetric::F1 => n4m::Metric::F1,
2153                HpoMetric::Logloss => n4m::Metric::Logloss,
2154            },
2155            seed: config.seed,
2156            n_startup_trials: config.n_startup_trials,
2157            max_resource: config.max_resource,
2158            reduction_factor: config.reduction_factor,
2159            ..OptimizerOptions::default()
2160        }
2161    }
2162    fn map_status(value: TrialStatus) -> HpoTrialStatus {
2163        match value {
2164            TrialStatus::Running => HpoTrialStatus::Running,
2165            TrialStatus::Completed => HpoTrialStatus::Completed,
2166            TrialStatus::Pruned => HpoTrialStatus::Pruned,
2167            TrialStatus::Failed => HpoTrialStatus::Failed,
2168            TrialStatus::Cancelled => HpoTrialStatus::Cancelled,
2169        }
2170    }
2171    fn snapshot_trial(value: &TrialSnapshot) -> HpoResult<HpoTrial> {
2172        let mut parameters = BTreeMap::new();
2173        for (name, parameter) in &value.parameters {
2174            parameters.insert(
2175                name.clone(),
2176                HpoTrialParameter {
2177                    name: name.clone(),
2178                    value: parameter.value,
2179                    native_kind: Some(map_parameter_kind(parameter.kind)),
2180                    category_type: parameter.category_type.map(map_category_type),
2181                    integer: parameter.integer,
2182                    active: parameter.active,
2183                    category_index: parameter.category_index,
2184                    category_label: parameter.category_label.clone(),
2185                },
2186            );
2187        }
2188        Ok(HpoTrial {
2189            id: value.id,
2190            ask_sequence: value.ask_sequence,
2191            terminal_sequence: value.terminal_sequence,
2192            parameters,
2193            parameter_order: value.parameter_order.clone(),
2194            status: map_status(value.status),
2195            score: value.score,
2196            rung: value.rung,
2197            duration: value.duration,
2198            intermediates: value
2199                .intermediates
2200                .iter()
2201                .map(|item| HpoIntermediate {
2202                    sequence: item.sequence,
2203                    step: item.step,
2204                    score: item.score,
2205                    should_prune: item.should_prune,
2206                })
2207                .collect(),
2208            failure: value.error.as_ref().map(|item| HpoFailure {
2209                code: item.code.clone(),
2210                message: item.message.clone(),
2211                retryable: item.retryable,
2212            }),
2213        })
2214    }
2215    fn map_parameter_kind(value: n4m::ParameterKind) -> HpoNativeParameterKind {
2216        match value {
2217            n4m::ParameterKind::Int => HpoNativeParameterKind::Int,
2218            n4m::ParameterKind::Float => HpoNativeParameterKind::Float,
2219            n4m::ParameterKind::LogInt => HpoNativeParameterKind::LogInt,
2220            n4m::ParameterKind::LogFloat => HpoNativeParameterKind::LogFloat,
2221            n4m::ParameterKind::Categorical => HpoNativeParameterKind::Categorical,
2222            n4m::ParameterKind::Ordinal => HpoNativeParameterKind::Ordinal,
2223            n4m::ParameterKind::SortedTuple => HpoNativeParameterKind::SortedTuple,
2224        }
2225    }
2226    fn map_category_type(value: n4m::CategoryType) -> HpoCategoryType {
2227        match value {
2228            n4m::CategoryType::Str => HpoCategoryType::String,
2229            n4m::CategoryType::Int => HpoCategoryType::Integer,
2230            n4m::CategoryType::Float => HpoCategoryType::Float,
2231            n4m::CategoryType::Bool => HpoCategoryType::Boolean,
2232        }
2233    }
2234    fn native_error(operation: &str, error: Error) -> HpoError {
2235        HpoError::Native {
2236            operation: operation.to_string(),
2237            error: to_native_error(error),
2238        }
2239    }
2240    fn to_native_error(error: Error) -> HpoNativeError {
2241        HpoNativeError {
2242            status: error.status,
2243            kind: format!("{:?}", error.kind).to_lowercase(),
2244            retryable: matches!(
2245                error.kind,
2246                ErrorKind::OutOfMemory
2247                    | ErrorKind::BackendUnavailable
2248                    | ErrorKind::Cancelled
2249                    | ErrorKind::Io
2250            ),
2251            message: error.message,
2252        }
2253    }
2254}
2255
2256#[cfg(feature = "methods-optimizer")]
2257use native::MethodsHpoStudy;
2258
2259#[cfg(test)]
2260mod tests {
2261    use super::*;
2262    #[cfg(feature = "methods-optimizer-local")]
2263    use crate::controller::{
2264        ArtifactPolicy, ControllerCapability, ControllerFitScope, ControllerManifest,
2265        ControllerRegistry, RngPolicy,
2266    };
2267    #[cfg(feature = "methods-optimizer-local")]
2268    use crate::graph::{GraphInterface, GraphSpec, NodeKind, NodeSpec, PortSchema};
2269    #[cfg(feature = "methods-optimizer-local")]
2270    use crate::metrics::RegressionMetricKind;
2271    #[cfg(feature = "methods-optimizer-local")]
2272    use crate::phase::Phase;
2273    #[cfg(feature = "methods-optimizer-local")]
2274    use crate::plan::{build_execution_plan, CampaignSpec, ExecutionPlan};
2275    #[cfg(feature = "methods-optimizer-local")]
2276    use crate::runtime::{
2277        ArtifactBackend, ArtifactMaterializationRequest, ArtifactRef, RuntimeController,
2278        RuntimeControllerRegistry, RuntimeHpoExecutionContext, RuntimeHpoIntermediate,
2279        RuntimeHpoIntermediateOutcome, RuntimeHpoProvenance, RuntimeHpoSelectionTarget,
2280        RuntimeHpoTerminal,
2281    };
2282
2283    #[cfg(feature = "methods-optimizer-local")]
2284    fn native_runtime() -> MethodsRuntime {
2285        let library_path = std::env::var_os("N4M_LIBRARY_PATH")
2286            .expect("methods native tests require an explicit N4M_LIBRARY_PATH");
2287        MethodsRuntime::configure(library_path).expect("configure explicit Methods test runtime")
2288    }
2289
2290    #[test]
2291    fn default_build_refuses_before_any_native_or_host_work() {
2292        assert_eq!(
2293            methods_optimizer_preflight(),
2294            if cfg!(feature = "methods-optimizer") {
2295                Ok(())
2296            } else {
2297                Err(HpoError::MethodsOptimizerFeatureDisabled)
2298            }
2299        );
2300    }
2301
2302    #[cfg(feature = "methods-optimizer")]
2303    #[test]
2304    fn methods_runtime_refuses_relative_library_paths_before_native_loading() {
2305        assert!(matches!(
2306            MethodsRuntime::configure("libn4m.so"),
2307            Err(HpoError::RuntimeConfiguration { reason })
2308                if reason == "libn4m path must be absolute"
2309        ));
2310    }
2311
2312    #[cfg(feature = "methods-optimizer-local")]
2313    #[test]
2314    fn methods_runtime_registers_both_controllers_atomically() {
2315        let runtime = native_runtime();
2316        let hpo_id = crate::ControllerId::new("controller:tuner.methods").unwrap();
2317        let mut registry = RuntimeControllerRegistry::new();
2318
2319        register_methods_runtime_controllers(&mut registry, hpo_id.clone(), runtime.clone())
2320            .unwrap();
2321        let pls_id = crate::ControllerId::new(METHODS_PLS_CONTROLLER_ID).unwrap();
2322        assert!(registry.get(&pls_id).is_some());
2323        assert!(registry.get(&hpo_id).is_some());
2324
2325        let error = register_methods_runtime_controllers(&mut registry, hpo_id.clone(), runtime)
2326            .unwrap_err();
2327        assert!(error.to_string().contains("duplicate runtime controller"));
2328        assert!(registry.get(&pls_id).is_some());
2329        assert!(registry.get(&hpo_id).is_some());
2330    }
2331
2332    #[cfg(feature = "methods-optimizer-local")]
2333    #[test]
2334    fn methods_pls_hydrated_payload_release_is_idempotent_and_handle_local() {
2335        let runtime = native_runtime();
2336        let context = n4m::Context::new().unwrap();
2337        let mut config = n4m::Config::new().unwrap();
2338        config.set_n_components(1).unwrap();
2339        let x_values = [1.0, 1.0, 2.0, 4.0, 3.0, 9.0, 4.0, 16.0];
2340        let y_values = [1.0, 2.0, 3.0, 4.0];
2341        let x = n4m::MatrixRef::row_major(&x_values, 4, 2).unwrap();
2342        let y = n4m::MatrixRef::row_major(&y_values, 4, 1).unwrap();
2343        let payload = n4m::Model::fit(&context, &config, x, y)
2344            .unwrap()
2345            .export_n4mm()
2346            .unwrap();
2347        let controller = MethodsPlsController::new(runtime);
2348        let controller_id = controller.controller_id().clone();
2349        let request = ArtifactMaterializationRequest {
2350            run_id: crate::RunId::new("run:methods-pls.release").unwrap(),
2351            bundle_id: crate::BundleId::new("bundle:methods-pls.release").unwrap(),
2352            node_id: crate::NodeId::new("model:methods-pls").unwrap(),
2353            phase: Phase::Predict,
2354            variant_id: None,
2355            controller_id: controller_id.clone(),
2356            artifact: ArtifactRef {
2357                id: crate::ArtifactId::new("artifact:methods-pls.release").unwrap(),
2358                kind: "n4m_model".to_string(),
2359                controller_id,
2360                backend: Some(ArtifactBackend::Raw),
2361                uri: Some("methods/release.n4mm".to_string()),
2362                content_fingerprint: Some(format!("{:x}", Sha256::digest(&payload))),
2363                size_bytes: Some(payload.len() as u64),
2364                plugin: None,
2365                plugin_version: None,
2366            },
2367            params_fingerprint: "params:methods-pls.release".to_string(),
2368            training_loss_fingerprint: None,
2369        };
2370
2371        let first = controller
2372            .hydrate_artifact_payload(&request, &payload)
2373            .unwrap();
2374        assert_eq!(controller.hydrated_payload_count().unwrap(), 1);
2375        controller
2376            .release_hydrated_artifact_payload(&first)
2377            .unwrap();
2378        controller
2379            .release_hydrated_artifact_payload(&first)
2380            .unwrap();
2381        assert_eq!(controller.hydrated_payload_count().unwrap(), 0);
2382
2383        let second = controller
2384            .hydrate_artifact_payload(&request, &payload)
2385            .unwrap();
2386        assert_ne!(first.handle, second.handle);
2387        controller
2388            .release_hydrated_artifact_payload(&second)
2389            .unwrap();
2390        assert_eq!(controller.hydrated_payload_count().unwrap(), 0);
2391    }
2392
2393    fn ledger_trial(score: f64) -> HpoTrial {
2394        HpoTrial {
2395            id: 7,
2396            ask_sequence: 3,
2397            terminal_sequence: Some(4),
2398            parameters: BTreeMap::new(),
2399            parameter_order: Vec::new(),
2400            status: HpoTrialStatus::Completed,
2401            score: Some(score),
2402            rung: 0,
2403            duration: 0.25,
2404            intermediates: vec![HpoIntermediate {
2405                sequence: 2,
2406                step: 0,
2407                score,
2408                should_prune: false,
2409            }],
2410            failure: None,
2411        }
2412    }
2413
2414    #[test]
2415    fn restored_ledger_accepts_only_one_ulp_score_drift_after_tcv1_projection() {
2416        let native = canonical_hpo_terminal_ledger(vec![ledger_trial(1.0)]).unwrap();
2417        let mut one_ulp = native.clone();
2418        one_ulp[0].score = Some(f64::from_bits(1.0_f64.to_bits() + 1));
2419        one_ulp[0].intermediates[0].score = f64::from_bits(1.0_f64.to_bits() + 1);
2420        let one_ulp = canonical_hpo_terminal_ledger(one_ulp).unwrap();
2421        assert!(hpo_terminal_trials_match(&native, &one_ulp));
2422
2423        let mut two_ulps = native.clone();
2424        two_ulps[0].score = Some(f64::from_bits(1.0_f64.to_bits() + 2));
2425        assert!(!hpo_terminal_trials_match(&native, &two_ulps));
2426
2427        let mut tampered = native.clone();
2428        tampered[0].score = Some(99.0);
2429        assert!(!hpo_terminal_trials_match(&native, &tampered));
2430    }
2431
2432    #[test]
2433    fn search_space_digest_is_canonical_and_order_sensitive() {
2434        let space = HpoSearchSpace {
2435            parameters: vec![HpoParameter::Int {
2436                name: "depth".into(),
2437                low: 1,
2438                high: 5,
2439                step: 1,
2440                log: false,
2441            }],
2442        };
2443        assert_eq!(space.fingerprint().unwrap(), space.fingerprint().unwrap());
2444        let swapped = HpoSearchSpace {
2445            parameters: vec![
2446                HpoParameter::Float {
2447                    name: "rate".into(),
2448                    low: 0.1,
2449                    high: 1.0,
2450                    step: 0.1,
2451                    log: false,
2452                },
2453                space.parameters[0].clone(),
2454            ],
2455        };
2456        assert_ne!(space.fingerprint().unwrap(), swapped.fingerprint().unwrap());
2457    }
2458    #[test]
2459    fn checkpoint_rejects_oversize_before_native_decoder() {
2460        let binding = HpoStudyBinding {
2461            controller_id: "controller:hpo".into(),
2462            study_id: "study:one".into(),
2463            search_space_fingerprint: "a".into(),
2464            optimizer_fingerprint: "b".into(),
2465        };
2466        let checkpoint = N4moptCheckpointArtifact {
2467            schema_version: 1,
2468            artifact_kind: N4MOPT_ARTIFACT_KIND.into(),
2469            format: N4MOPT_FORMAT.into(),
2470            binding,
2471            methods_abi: "n4m-abi-2.2".into(),
2472            opaque_payload: vec![0; MAX_N4MOPT_CHECKPOINT_BYTES + 1],
2473            payload_sha256: "x".into(),
2474        };
2475        assert!(matches!(
2476            checkpoint.validate(),
2477            Err(HpoError::InvalidCheckpoint { .. })
2478        ));
2479    }
2480
2481    #[cfg(feature = "methods-optimizer-local")]
2482    fn native_config() -> MethodsHpoStudyConfig {
2483        MethodsHpoStudyConfig {
2484            controller_id: "controller:methods-hpo".into(),
2485            study_id: "study:native-lifecycle".into(),
2486            methods_abi: "n4m-abi-2.2".into(),
2487            search_space: HpoSearchSpace {
2488                parameters: vec![HpoParameter::Int {
2489                    name: "n_components".into(),
2490                    low: 1,
2491                    high: 3,
2492                    step: 1,
2493                    log: false,
2494                }],
2495            },
2496            optimizer: HpoOptimizerConfig {
2497                sampler: HpoSampler::Random,
2498                pruner: HpoPruner::None,
2499                direction: HpoDirection::Minimize,
2500                metric: HpoMetric::Rmse,
2501                seed: 7,
2502                n_startup_trials: 1,
2503                max_resource: 0,
2504                reduction_factor: 0,
2505            },
2506        }
2507    }
2508
2509    #[cfg(feature = "methods-optimizer-local")]
2510    fn native_hpo_manifest(id: &str, kind: NodeKind) -> ControllerManifest {
2511        ControllerManifest {
2512            controller_id: crate::ControllerId::new(id).unwrap(),
2513            controller_version: "native-hpo-test".to_string(),
2514            operator_kind: kind,
2515            priority: 0,
2516            supported_phases: BTreeSet::from([Phase::FitCv]),
2517            input_ports: Vec::new(),
2518            output_ports: Vec::new(),
2519            data_requirements: None,
2520            capabilities: BTreeSet::from([ControllerCapability::Deterministic]),
2521            operator_selectors: Vec::new(),
2522            fit_scope: ControllerFitScope::FoldTrain,
2523            rng_policy: RngPolicy::UsesCoreSeed,
2524            artifact_policy: ArtifactPolicy::Serializable,
2525        }
2526    }
2527
2528    #[cfg(feature = "methods-optimizer-local")]
2529    fn native_hpo_node(id: &str, kind: NodeKind) -> NodeSpec {
2530        NodeSpec {
2531            id: crate::NodeId::new(id).unwrap(),
2532            kind,
2533            operator: None,
2534            params: BTreeMap::new(),
2535            ports: PortSchema {
2536                inputs: Vec::new(),
2537                outputs: Vec::new(),
2538            },
2539            metadata: BTreeMap::new(),
2540            seed_label: None,
2541        }
2542    }
2543
2544    #[cfg(feature = "methods-optimizer-local")]
2545    fn attested_native_hpo_context() -> (
2546        ExecutionPlan,
2547        RuntimeHpoExecutionContext,
2548        crate::runtime::RuntimeHpoCampaignTask,
2549    ) {
2550        let target_node_id = crate::NodeId::new("model:methods-pls").unwrap();
2551        let controller_id = crate::ControllerId::new("controller:methods-hpo").unwrap();
2552        let mut registry = ControllerRegistry::new();
2553        registry
2554            .register(native_hpo_manifest(
2555                "controller:methods-pls",
2556                NodeKind::Model,
2557            ))
2558            .unwrap();
2559        let plan = build_execution_plan(
2560            "plan:methods-hpo-checkpoint",
2561            GraphSpec {
2562                id: "graph:methods-hpo-checkpoint".to_string(),
2563                interface: GraphInterface::default(),
2564                nodes: vec![native_hpo_node("model:methods-pls", NodeKind::Model)],
2565                edges: Vec::new(),
2566                search_space_fingerprint: None,
2567                metadata: BTreeMap::new(),
2568            },
2569            CampaignSpec {
2570                inner_cv: None,
2571                id: "campaign:methods-hpo-checkpoint".to_string(),
2572                root_seed: Some(17),
2573                leakage_policy: Default::default(),
2574                aggregation_policy: Default::default(),
2575                split_invocation: None,
2576                generation: Default::default(),
2577                shape_plans: BTreeMap::new(),
2578                data_bindings: BTreeMap::new(),
2579                branch_view_plans: Vec::new(),
2580                metadata: BTreeMap::new(),
2581            },
2582            &registry,
2583        )
2584        .unwrap();
2585        let context = RuntimeHpoExecutionContext {
2586            operation_id: "hpo:methods".to_string(),
2587            controller_id: controller_id.clone(),
2588            target_node_id: target_node_id.clone(),
2589            base_variant: plan.variants[0].clone(),
2590            trial_budget_total: 2,
2591            study: native_config(),
2592            parameter_paths: BTreeMap::from([(
2593                "n_components".to_string(),
2594                "n_components".to_string(),
2595            )]),
2596            resume_checkpoint: None,
2597            resume_variants: BTreeMap::new(),
2598            resume_terminal_trials: Vec::new(),
2599            selection: RuntimeHpoSelectionTarget {
2600                producer_node: target_node_id.clone(),
2601                producer_port: "prediction".to_string(),
2602                metric: RegressionMetricKind::Rmse,
2603                direction: HpoDirection::Minimize,
2604            },
2605            provenance: RuntimeHpoProvenance {
2606                graph_fingerprint: plan.graph_fingerprint.clone(),
2607                campaign_fingerprint: plan.campaign_fingerprint.clone(),
2608                controller_fingerprint: plan.controller_fingerprint.clone(),
2609                data_identities_fingerprint: "data-identities:methods-hpo".to_string(),
2610                fold_set_fingerprint: None,
2611                training_influence_fingerprint: "influence:methods-hpo".to_string(),
2612                relation_fingerprint: "relations:methods-hpo".to_string(),
2613            },
2614        };
2615        context.validate_for_plan(&plan).unwrap();
2616        let task = crate::runtime::RuntimeHpoCampaignTask {
2617            run_id: crate::RunId::new("run:methods-hpo-checkpoint").unwrap(),
2618            operation_id: "hpo:methods".to_string(),
2619            controller_id: controller_id.clone(),
2620            target_node_id,
2621            seed: Some(17),
2622        };
2623        assert_eq!(task.controller_id, controller_id);
2624        (plan, context, task)
2625    }
2626
2627    #[cfg(feature = "methods-optimizer-local")]
2628    fn proposal_components(proposal: &crate::runtime::RuntimeHpoProposal) -> i64 {
2629        let choice = proposal.variant.choices.get("native_methods_hpo").unwrap();
2630        let override_ = choice.param_overrides.first().unwrap();
2631        assert_eq!(override_.params.len(), 1);
2632        override_.params["n_components"].as_i64().unwrap()
2633    }
2634
2635    #[cfg(feature = "methods-optimizer-local")]
2636    fn assert_runtime_refusal(error: crate::DagMlError) {
2637        assert!(matches!(error, crate::DagMlError::RuntimeValidation(_)));
2638    }
2639
2640    #[cfg(feature = "methods-optimizer-local")]
2641    #[test]
2642    fn registered_methods_session_checkpoints_restores_and_refuses_tampering() {
2643        let runtime = native_runtime();
2644        let (plan, context, task) = attested_native_hpo_context();
2645        let controller_id = task.controller_id.clone();
2646        let mut controllers = RuntimeControllerRegistry::new();
2647        controllers
2648            .register(Box::new(MethodsHpoController::new(
2649                controller_id.clone(),
2650                runtime,
2651            )))
2652            .unwrap();
2653        let controller = controllers.get(&controller_id).unwrap();
2654
2655        let mut session = controller.create_tuner_session(&task, &context).unwrap();
2656        let first = session.ask().unwrap().unwrap();
2657        assert!((1..=3).contains(&proposal_components(&first)));
2658        assert_eq!(
2659            session
2660                .report_intermediate(RuntimeHpoIntermediate {
2661                    trial_id: first.trial_id,
2662                    step: 0,
2663                    score: 1.5,
2664                })
2665                .unwrap(),
2666            RuntimeHpoIntermediateOutcome::Continue
2667        );
2668        session
2669            .tell(first.trial_id, RuntimeHpoTerminal::Completed { score: 1.0 })
2670            .unwrap();
2671        let checkpoint = session.checkpoint().unwrap();
2672        checkpoint.validate().unwrap();
2673        assert_eq!(checkpoint.binding.controller_id, controller_id.as_str());
2674        assert_eq!(checkpoint.binding.study_id, context.study.study_id);
2675        assert_eq!(checkpoint.methods_abi, context.study.methods_abi);
2676
2677        // Inspect the native N4MOPT trace rather than a synthetic session
2678        // record: the checkpoint is the only state crossing this boundary.
2679        let checkpoint_trace = MethodsHpoStudy::restore(context.study.clone(), &checkpoint)
2680            .unwrap()
2681            .trials()
2682            .unwrap();
2683        assert_eq!(checkpoint_trace.len(), 1);
2684        assert_eq!(checkpoint_trace[0].id, first.trial_id);
2685        assert_eq!(checkpoint_trace[0].status, HpoTrialStatus::Completed);
2686        assert_eq!(checkpoint_trace[0].score, Some(1.0));
2687        assert_eq!(
2688            MethodsHpoStudy::restore(context.study.clone(), &checkpoint)
2689                .unwrap()
2690                .best()
2691                .unwrap()
2692                .unwrap()
2693                .trial
2694                .id,
2695            first.trial_id
2696        );
2697
2698        let mut expected = MethodsHpoStudy::restore(context.study.clone(), &checkpoint).unwrap();
2699        assert_eq!(expected.trials().unwrap(), checkpoint_trace);
2700        assert_eq!(expected.best().unwrap().unwrap().trial.id, first.trial_id);
2701        let expected_next = expected.ask().unwrap();
2702        let mut resumed_context = context.clone();
2703        resumed_context.resume_checkpoint = Some(checkpoint.clone());
2704        resumed_context.resume_terminal_trials = vec![crate::runtime::RuntimeHpoTerminalSnapshot {
2705            trial: checkpoint_trace[0].clone(),
2706            variant_id: Some(first.variant.variant_id.clone()),
2707        }];
2708        resumed_context.validate_for_plan(&plan).unwrap();
2709
2710        // The persisted terminal ledger is an independent, typed attestation
2711        // of the opaque native payload.  A modified ledger must be rejected
2712        // by the controller-owned restore factory, before it can expose an
2713        // `ask` handle to the scheduler.
2714        let mut tampered_ledger = resumed_context.clone();
2715        tampered_ledger.resume_terminal_trials[0].trial.score = Some(99.0);
2716        let error = match controller.create_tuner_session(&task, &tampered_ledger) {
2717            Err(error) => error,
2718            Ok(_) => panic!("tampered restored terminal ledger unexpectedly created a session"),
2719        };
2720        assert_runtime_refusal(error);
2721
2722        let mut resumed = controller
2723            .create_tuner_session(&task, &resumed_context)
2724            .unwrap();
2725        let resumed_next = resumed.ask().unwrap().unwrap();
2726        assert_eq!(resumed_next.trial_id, expected_next.id);
2727        assert_eq!(
2728            proposal_components(&resumed_next),
2729            expected_next.parameters["n_components"].value as i64
2730        );
2731        resumed
2732            .report_intermediate(RuntimeHpoIntermediate {
2733                trial_id: resumed_next.trial_id,
2734                step: 0,
2735                score: 0.5,
2736            })
2737            .unwrap();
2738        resumed
2739            .tell(
2740                resumed_next.trial_id,
2741                RuntimeHpoTerminal::Completed { score: 0.25 },
2742            )
2743            .unwrap();
2744        let resumed_checkpoint = resumed.checkpoint().unwrap();
2745        let resumed_trace = MethodsHpoStudy::restore(context.study.clone(), &resumed_checkpoint)
2746            .unwrap()
2747            .trials()
2748            .unwrap();
2749        assert_eq!(resumed_trace.len(), 2);
2750        assert_eq!(resumed_trace[1].id, resumed_next.trial_id);
2751        assert_eq!(resumed_trace[1].status, HpoTrialStatus::Completed);
2752        assert_eq!(resumed_trace[1].score, Some(0.25));
2753        assert_eq!(
2754            MethodsHpoStudy::restore(context.study.clone(), &resumed_checkpoint)
2755                .unwrap()
2756                .best()
2757                .unwrap()
2758                .unwrap()
2759                .trial
2760                .id,
2761            resumed_next.trial_id
2762        );
2763
2764        let mut wrong_abi = resumed_context.clone();
2765        wrong_abi.study.methods_abi = "n4m-abi-wrong".to_string();
2766        wrong_abi.validate_for_plan(&plan).unwrap();
2767        let error = match controller.create_tuner_session(&task, &wrong_abi) {
2768            Err(error) => error,
2769            Ok(_) => panic!("mismatched Methods ABI unexpectedly restored a session"),
2770        };
2771        assert_runtime_refusal(error);
2772
2773        let mut wrong_binding = resumed_context.clone();
2774        wrong_binding
2775            .resume_checkpoint
2776            .as_mut()
2777            .unwrap()
2778            .binding
2779            .study_id = "study:wrong-binding".to_string();
2780        wrong_binding.validate_for_plan(&plan).unwrap();
2781        let error = match controller.create_tuner_session(&task, &wrong_binding) {
2782            Err(error) => error,
2783            Ok(_) => panic!("mismatched checkpoint binding unexpectedly restored a session"),
2784        };
2785        assert_runtime_refusal(error);
2786
2787        let mut wrong_checksum = resumed_context;
2788        wrong_checksum
2789            .resume_checkpoint
2790            .as_mut()
2791            .unwrap()
2792            .opaque_payload[0] ^= 1;
2793        assert_runtime_refusal(wrong_checksum.validate_for_plan(&plan).unwrap_err());
2794        let error = match controller.create_tuner_session(&task, &wrong_checksum) {
2795            Err(error) => error,
2796            Ok(_) => panic!("bad checkpoint checksum unexpectedly restored a session"),
2797        };
2798        assert_runtime_refusal(error);
2799    }
2800
2801    #[cfg(feature = "methods-optimizer-local")]
2802    #[test]
2803    fn real_n4m_lifecycle_batch_trials_best_and_checkpoint() {
2804        let _runtime = native_runtime();
2805        let config = native_config();
2806        let mut study = MethodsHpoStudy::create(config.clone()).unwrap();
2807        let batch = study.ask_batch(2).unwrap();
2808        assert_eq!(batch.trials.len(), 2);
2809        assert!(batch.native_error.is_none());
2810        assert!(batch.trials.iter().all(|trial| trial.id >= 0));
2811        assert_eq!(batch.trials[0].parameter_order, vec!["n_components"]);
2812
2813        study
2814            .report_intermediate(batch.trials[0].id, 0, 2.0)
2815            .unwrap();
2816        study
2817            .tell(batch.trials[0].id, HpoTerminal::Completed { score: 1.0 })
2818            .unwrap();
2819        study
2820            .tell(
2821                batch.trials[1].id,
2822                HpoTerminal::Failed {
2823                    failure: HpoFailure {
2824                        code: "EVALUATION_FAILED".into(),
2825                        message: "controlled test failure".into(),
2826                        retryable: true,
2827                    },
2828                },
2829            )
2830            .unwrap();
2831
2832        let trials = study.trials().unwrap();
2833        assert_eq!(trials.len(), 2);
2834        assert_eq!(trials[0].status, HpoTrialStatus::Completed);
2835        assert_eq!(trials[0].score, Some(1.0));
2836        assert_eq!(trials[1].status, HpoTrialStatus::Failed);
2837        assert!(trials[1].failure.as_ref().unwrap().retryable);
2838        assert_eq!(study.best().unwrap().unwrap().score, 1.0);
2839        assert!(study
2840            .events()
2841            .iter()
2842            .any(|event| matches!(event, HpoEvent::Intermediate { step: 0, .. })));
2843
2844        let checkpoint = study.save_checkpoint().unwrap();
2845        let restored = MethodsHpoStudy::restore(config, &checkpoint).unwrap();
2846        assert_eq!(restored.trials().unwrap().len(), 2);
2847    }
2848
2849    #[cfg(feature = "methods-optimizer-local")]
2850    #[test]
2851    fn real_tpe_pruner_failure_trace_and_checkpoint_resume_are_native() {
2852        let _runtime = native_runtime();
2853        let mut config = native_config();
2854        config.optimizer.sampler = HpoSampler::Tpe;
2855        config.optimizer.pruner = HpoPruner::Median;
2856        config.optimizer.n_startup_trials = 2;
2857        config.optimizer.seed = 51;
2858        let mut study = MethodsHpoStudy::create(config.clone()).unwrap();
2859
2860        // Exercise a native terminal failure before any candidate scores.  The
2861        // trace must preserve the structured native failure rather than turn
2862        // it into a coordinator-side synthetic score.
2863        let failed = study.ask().unwrap();
2864        let failed = study
2865            .tell(
2866                failed.id,
2867                HpoTerminal::Failed {
2868                    failure: HpoFailure {
2869                        code: "CV_PROVIDER_FAILURE".into(),
2870                        message: "controlled fold materialization failure".into(),
2871                        retryable: false,
2872                    },
2873                },
2874            )
2875            .unwrap();
2876        assert_eq!(failed.status, HpoTrialStatus::Failed);
2877        assert_eq!(failed.failure.unwrap().code, "CV_PROVIDER_FAILURE");
2878
2879        // These three scores model the OOF-CV intermediate produced after
2880        // each scheduler evaluation. `tell_intermediate` is the only route
2881        // used for pruning: libn4m terminalizes the bad third candidate as
2882        // PRUNED, and DAG-ML must not issue a second terminal tell.
2883        let first = study.ask().unwrap();
2884        assert!(!study.report_intermediate(first.id, 0, 1.0).unwrap());
2885        let second = study.ask().unwrap();
2886        assert!(!study.report_intermediate(second.id, 0, 2.0).unwrap());
2887        let third = study.ask().unwrap();
2888        assert!(study.report_intermediate(third.id, 0, 9.0).unwrap());
2889
2890        let trials = study.trials().unwrap();
2891        let pruned = trials.iter().find(|trial| trial.id == third.id).unwrap();
2892        assert_eq!(pruned.status, HpoTrialStatus::Pruned);
2893        assert!(pruned.terminal_sequence.is_some());
2894        assert!(pruned
2895            .intermediates
2896            .iter()
2897            .any(|item| item.step == 0 && item.score == 9.0 && item.should_prune));
2898        assert!(study.events().iter().any(|event| {
2899            matches!(event, HpoEvent::Terminal { trial_id, status: HpoTrialStatus::Failed, .. } if *trial_id == failed.id)
2900        }));
2901        assert!(study.events().iter().any(|event| {
2902            matches!(event, HpoEvent::Terminal { trial_id, status: HpoTrialStatus::Pruned, .. } if *trial_id == third.id)
2903        }));
2904
2905        let checkpoint = study.save_checkpoint().unwrap();
2906        // The bundle stores the opaque N4MOPT member through serde JSON, so
2907        // make the resume assertion cross that durable public boundary rather
2908        // than restoring from the same in-memory envelope.
2909        let checkpoint: N4moptCheckpointArtifact =
2910            serde_json::from_str(&serde_json::to_string(&checkpoint).unwrap()).unwrap();
2911        let mut resumed = MethodsHpoStudy::restore(config, &checkpoint).unwrap();
2912        for _ in 0..4 {
2913            let uninterrupted = study.ask().unwrap();
2914            let restored = resumed.ask().unwrap();
2915            assert_eq!(uninterrupted.id, restored.id);
2916            assert_eq!(uninterrupted.parameter_order, restored.parameter_order);
2917            assert_eq!(uninterrupted.parameters, restored.parameters);
2918        }
2919    }
2920
2921    #[cfg(feature = "methods-optimizer-local")]
2922    #[test]
2923    fn malformed_checkpoint_reaches_native_n4mopt_decoder_as_typed_error() {
2924        let _runtime = native_runtime();
2925        let config = native_config();
2926        let study = MethodsHpoStudy::create(config.clone()).unwrap();
2927        let mut checkpoint = study.save_checkpoint().unwrap();
2928        checkpoint.opaque_payload[0] ^= 1;
2929        checkpoint.payload_sha256 = payload_sha256(&checkpoint.opaque_payload);
2930        assert!(matches!(
2931            MethodsHpoStudy::restore(config, &checkpoint),
2932            Err(HpoError::Native { operation, .. }) if operation == "load_n4mopt"
2933        ));
2934    }
2935}