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