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