1use 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";
27pub const MAX_N4MOPT_CHECKPOINT_BYTES: usize = 64 * 1024 * 1024;
30
31pub(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 operation.remove("trials");
45 }
46 stable_json_fingerprint(&canonical)
47}
48
49pub type HpoResult<T> = std::result::Result<T, HpoError>;
50
51#[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#[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 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#[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 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 #[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#[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#[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
601pub 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 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#[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
795pub 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
808pub const METHODS_PLS_CONTROLLER_ID: &str = "controller:methods.pls";
813
814#[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(¶meter.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 pub struct MethodsPlsController {
1159 id: ControllerId,
1160 next_handle: AtomicU64,
1161 exported_n4mm_by_artifact: Mutex<BTreeMap<ArtifactId, Vec<u8>>>,
1164 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 #[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 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 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 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 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 #[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 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 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 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 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 return Err(error);
1911 }
1912 let failure = HpoFailure {
1913 code: "HPO_EVALUATION".to_string(),
1914 message: error.to_string(),
1915 retryable: true,
1916 };
1917 let _ = self.tell(trial.id, HpoTerminal::Failed { failure });
1920 return Err(error);
1921 }
1922 };
1923 if let Some(snapshot) = pruned {
1924 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 ®istry,
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 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 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 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 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 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}