1use std::collections::HashMap;
7
8#[derive(Debug, Clone)]
12pub struct ParamStateSnapshot {
13 pub param_name: String,
14 pub step: u64,
15 pub first_moment: Option<Vec<f64>>,
17 pub second_moment: Option<Vec<f64>>,
19 pub momentum: Option<Vec<f64>>,
21 pub ema: Option<Vec<f64>>,
23 pub grad_norm: Option<f64>,
25}
26
27impl ParamStateSnapshot {
28 pub fn new(param_name: impl Into<String>) -> Self {
30 Self {
31 param_name: param_name.into(),
32 step: 0,
33 first_moment: None,
34 second_moment: None,
35 momentum: None,
36 ema: None,
37 grad_norm: None,
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq)]
46pub enum OptimizerKind {
47 Adam,
48 AdamW,
49 SGD,
50 Lion,
51 RMSProp,
52}
53
54impl std::fmt::Display for OptimizerKind {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 match self {
57 Self::Adam => write!(f, "Adam"),
58 Self::AdamW => write!(f, "AdamW"),
59 Self::SGD => write!(f, "SGD"),
60 Self::Lion => write!(f, "Lion"),
61 Self::RMSProp => write!(f, "RMSProp"),
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
70pub struct SurgeryConfig {
71 pub from: OptimizerKind,
72 pub to: OptimizerKind,
73 pub transfer_momentum: bool,
75 pub reset_step: bool,
77 pub momentum_scale: f64,
79}
80
81impl Default for SurgeryConfig {
82 fn default() -> Self {
83 Self {
84 from: OptimizerKind::Adam,
85 to: OptimizerKind::SGD,
86 transfer_momentum: true,
87 reset_step: false,
88 momentum_scale: 1.0,
89 }
90 }
91}
92
93pub struct MigrationReport {
97 pub params_migrated: usize,
98 pub params_with_transferred_momentum: usize,
99 pub params_with_reset_momentum: usize,
100 pub steps_preserved: usize,
101 pub steps_reset: usize,
102 pub from: OptimizerKind,
103 pub to: OptimizerKind,
104}
105
106impl std::fmt::Display for MigrationReport {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 write!(
109 f,
110 "MigrationReport {{ from: {}, to: {}, params_migrated: {}, \
111 momentum_transferred: {}, momentum_reset: {}, \
112 steps_preserved: {}, steps_reset: {} }}",
113 self.from,
114 self.to,
115 self.params_migrated,
116 self.params_with_transferred_momentum,
117 self.params_with_reset_momentum,
118 self.steps_preserved,
119 self.steps_reset,
120 )
121 }
122}
123
124#[derive(Debug, thiserror::Error)]
128pub enum SurgeryError {
129 #[error("Invalid migration: {0}")]
130 InvalidMigration(String),
131 #[error("State incompatible: {0}")]
132 IncompatibleState(String),
133 #[error("Empty states")]
134 EmptyStates,
135}
136
137pub struct OptimizerSurgeon {
141 config: SurgeryConfig,
142}
143
144impl OptimizerSurgeon {
145 pub fn new(config: SurgeryConfig) -> Self {
147 Self { config }
148 }
149
150 pub fn validate(
152 &self,
153 states: &HashMap<String, ParamStateSnapshot>,
154 ) -> Result<(), SurgeryError> {
155 if states.is_empty() {
156 return Err(SurgeryError::EmptyStates);
157 }
158
159 match (&self.config.from, &self.config.to) {
161 (OptimizerKind::Adam, OptimizerKind::SGD)
163 | (OptimizerKind::Adam, OptimizerKind::Lion)
164 | (OptimizerKind::Adam, OptimizerKind::Adam)
165 | (OptimizerKind::Adam, OptimizerKind::AdamW)
166 | (OptimizerKind::Adam, OptimizerKind::RMSProp)
167 | (OptimizerKind::AdamW, OptimizerKind::Adam)
168 | (OptimizerKind::AdamW, OptimizerKind::AdamW)
169 | (OptimizerKind::AdamW, OptimizerKind::SGD)
170 | (OptimizerKind::AdamW, OptimizerKind::Lion)
171 | (OptimizerKind::AdamW, OptimizerKind::RMSProp)
172 | (OptimizerKind::SGD, OptimizerKind::Adam)
173 | (OptimizerKind::SGD, OptimizerKind::AdamW)
174 | (OptimizerKind::SGD, OptimizerKind::SGD)
175 | (OptimizerKind::SGD, OptimizerKind::Lion)
176 | (OptimizerKind::SGD, OptimizerKind::RMSProp)
177 | (OptimizerKind::Lion, OptimizerKind::Adam)
178 | (OptimizerKind::Lion, OptimizerKind::AdamW)
179 | (OptimizerKind::Lion, OptimizerKind::SGD)
180 | (OptimizerKind::Lion, OptimizerKind::Lion)
181 | (OptimizerKind::Lion, OptimizerKind::RMSProp)
182 | (OptimizerKind::RMSProp, OptimizerKind::Adam)
183 | (OptimizerKind::RMSProp, OptimizerKind::AdamW)
184 | (OptimizerKind::RMSProp, OptimizerKind::SGD)
185 | (OptimizerKind::RMSProp, OptimizerKind::Lion)
186 | (OptimizerKind::RMSProp, OptimizerKind::RMSProp) => {},
187 }
188
189 for (name, state) in states.iter() {
191 match &self.config.from {
192 OptimizerKind::Adam | OptimizerKind::AdamW => {
193 if self.config.transfer_momentum && state.first_moment.is_none() {
195 return Err(SurgeryError::IncompatibleState(format!(
196 "param '{}': transfer_momentum=true but first_moment is None for {} source",
197 name, self.config.from,
198 )));
199 }
200 },
201 OptimizerKind::SGD => {
202 if self.config.transfer_momentum && state.momentum.is_none() {
204 return Err(SurgeryError::IncompatibleState(format!(
205 "param '{}': transfer_momentum=true but momentum is None for SGD source",
206 name,
207 )));
208 }
209 },
210 OptimizerKind::Lion => {
211 if self.config.transfer_momentum && state.ema.is_none() {
213 return Err(SurgeryError::IncompatibleState(format!(
214 "param '{}': transfer_momentum=true but ema is None for Lion source",
215 name,
216 )));
217 }
218 },
219 OptimizerKind::RMSProp => {
220 },
222 }
223 }
224
225 Ok(())
226 }
227
228 pub fn migrate(
232 &self,
233 states: &HashMap<String, ParamStateSnapshot>,
234 ) -> Result<HashMap<String, ParamStateSnapshot>, SurgeryError> {
235 if states.is_empty() {
236 return Err(SurgeryError::EmptyStates);
237 }
238
239 let mut result = HashMap::with_capacity(states.len());
240
241 for (name, src) in states.iter() {
242 let new_state = self.migrate_single(src)?;
243 result.insert(name.clone(), new_state);
244 }
245
246 Ok(result)
247 }
248
249 fn migrate_single(&self, src: &ParamStateSnapshot) -> Result<ParamStateSnapshot, SurgeryError> {
251 let mut dst = ParamStateSnapshot::new(src.param_name.clone());
252
253 dst.step = if self.config.reset_step { 0 } else { src.step };
255
256 dst.grad_norm = src.grad_norm;
258
259 match (&self.config.from, &self.config.to) {
260 (OptimizerKind::Adam | OptimizerKind::AdamW, OptimizerKind::SGD) => {
262 if self.config.transfer_momentum {
263 dst.momentum = src
264 .first_moment
265 .as_ref()
266 .map(|m| m.iter().map(|v| v * self.config.momentum_scale).collect());
267 }
268 },
270
271 (OptimizerKind::Adam | OptimizerKind::AdamW, OptimizerKind::Lion) => {
273 if self.config.transfer_momentum {
274 dst.ema = src
275 .first_moment
276 .as_ref()
277 .map(|m| m.iter().map(|v| v * self.config.momentum_scale).collect());
278 }
279 },
281
282 (
284 OptimizerKind::Adam | OptimizerKind::AdamW,
285 OptimizerKind::Adam | OptimizerKind::AdamW,
286 ) => {
287 if self.config.transfer_momentum {
289 dst.first_moment = src
290 .first_moment
291 .as_ref()
292 .map(|m| m.iter().map(|v| v * self.config.momentum_scale).collect());
293 }
294 dst.second_moment = src.second_moment.clone();
295 },
296
297 (OptimizerKind::Adam | OptimizerKind::AdamW, OptimizerKind::RMSProp) => {
299 dst.second_moment = src.second_moment.clone();
301 },
302
303 (OptimizerKind::SGD, OptimizerKind::Adam | OptimizerKind::AdamW) => {
305 if self.config.transfer_momentum {
306 dst.first_moment = src
307 .momentum
308 .as_ref()
309 .map(|m| m.iter().map(|v| v * self.config.momentum_scale).collect());
310 }
311 dst.second_moment = None;
313 },
314
315 (OptimizerKind::SGD, OptimizerKind::Lion) => {
317 if self.config.transfer_momentum {
318 dst.ema = src
319 .momentum
320 .as_ref()
321 .map(|m| m.iter().map(|v| v * self.config.momentum_scale).collect());
322 }
323 },
324
325 (OptimizerKind::SGD, OptimizerKind::SGD) => {
327 if self.config.transfer_momentum {
328 dst.momentum = src
329 .momentum
330 .as_ref()
331 .map(|m| m.iter().map(|v| v * self.config.momentum_scale).collect());
332 }
333 },
334
335 (OptimizerKind::SGD, OptimizerKind::RMSProp) => {
337 },
339
340 (OptimizerKind::Lion, OptimizerKind::Adam | OptimizerKind::AdamW) => {
342 if self.config.transfer_momentum {
343 dst.first_moment = src
344 .ema
345 .as_ref()
346 .map(|e| e.iter().map(|v| v * self.config.momentum_scale).collect());
347 }
348 dst.second_moment = None;
349 },
350
351 (OptimizerKind::Lion, OptimizerKind::SGD) => {
353 if self.config.transfer_momentum {
354 dst.momentum = src
355 .ema
356 .as_ref()
357 .map(|e| e.iter().map(|v| v * self.config.momentum_scale).collect());
358 }
359 },
360
361 (OptimizerKind::Lion, OptimizerKind::Lion) => {
363 if self.config.transfer_momentum {
364 dst.ema = src
365 .ema
366 .as_ref()
367 .map(|e| e.iter().map(|v| v * self.config.momentum_scale).collect());
368 }
369 },
370
371 (OptimizerKind::Lion, OptimizerKind::RMSProp) => {
373 },
375
376 (OptimizerKind::RMSProp, OptimizerKind::Adam | OptimizerKind::AdamW) => {
378 dst.second_moment = src.second_moment.clone();
380 },
381
382 (OptimizerKind::RMSProp, OptimizerKind::SGD) => {
384 },
386
387 (OptimizerKind::RMSProp, OptimizerKind::Lion) => {
389 },
391
392 (OptimizerKind::RMSProp, OptimizerKind::RMSProp) => {
394 dst.second_moment = src.second_moment.clone();
395 },
396 }
397
398 Ok(dst)
399 }
400
401 pub fn migration_report(
403 &self,
404 before: &HashMap<String, ParamStateSnapshot>,
405 after: &HashMap<String, ParamStateSnapshot>,
406 ) -> MigrationReport {
407 let params_migrated = after.len();
408 let mut params_with_transferred_momentum = 0usize;
409 let mut params_with_reset_momentum = 0usize;
410 let mut steps_preserved = 0usize;
411 let mut steps_reset = 0usize;
412
413 for (name, dst) in after.iter() {
414 let src_step = before.get(name).map(|s| s.step).unwrap_or(0);
415
416 if dst.step == 0 && src_step > 0 {
418 steps_reset += 1;
419 } else if dst.step == src_step {
420 steps_preserved += 1;
421 }
422
423 let has_transferred =
425 dst.momentum.is_some() || dst.first_moment.is_some() || dst.ema.is_some();
426
427 let src_had_transferable = before
428 .get(name)
429 .map(|s| s.first_moment.is_some() || s.momentum.is_some() || s.ema.is_some())
430 .unwrap_or(false);
431
432 if has_transferred && src_had_transferable {
433 params_with_transferred_momentum += 1;
434 } else if src_had_transferable && !has_transferred {
435 params_with_reset_momentum += 1;
436 }
437 }
438
439 MigrationReport {
440 params_migrated,
441 params_with_transferred_momentum,
442 params_with_reset_momentum,
443 steps_preserved,
444 steps_reset,
445 from: self.config.from.clone(),
446 to: self.config.to.clone(),
447 }
448 }
449}
450
451#[cfg(test)]
454mod tests {
455 use super::*;
456
457 fn make_adam_state(name: &str, step: u64, size: usize) -> ParamStateSnapshot {
458 let mut s = ParamStateSnapshot::new(name);
459 s.step = step;
460 s.first_moment = Some(vec![0.1; size]);
461 s.second_moment = Some(vec![0.01; size]);
462 s.grad_norm = Some(1.0);
463 s
464 }
465
466 fn make_sgd_state(name: &str, step: u64, size: usize) -> ParamStateSnapshot {
467 let mut s = ParamStateSnapshot::new(name);
468 s.step = step;
469 s.momentum = Some(vec![0.2; size]);
470 s
471 }
472
473 fn make_lion_state(name: &str, step: u64, size: usize) -> ParamStateSnapshot {
474 let mut s = ParamStateSnapshot::new(name);
475 s.step = step;
476 s.ema = Some(vec![0.05; size]);
477 s
478 }
479
480 fn single_map(state: ParamStateSnapshot) -> HashMap<String, ParamStateSnapshot> {
481 let mut m = HashMap::new();
482 m.insert(state.param_name.clone(), state);
483 m
484 }
485
486 #[test]
489 fn test_adam_to_sgd_transfer_momentum() {
490 let config = SurgeryConfig {
491 from: OptimizerKind::Adam,
492 to: OptimizerKind::SGD,
493 transfer_momentum: true,
494 reset_step: false,
495 momentum_scale: 1.0,
496 };
497 let surgeon = OptimizerSurgeon::new(config);
498 let states = single_map(make_adam_state("w", 10, 4));
499 let result = surgeon.migrate(&states).expect("migrate");
500 let dst = result.get("w").expect("w");
501 assert!(dst.momentum.is_some());
502 let mom = dst.momentum.as_ref().expect("momentum");
503 assert_eq!(mom.len(), 4);
504 assert!((mom[0] - 0.1).abs() < 1e-10);
505 assert!(dst.second_moment.is_none());
507 assert!(dst.first_moment.is_none());
509 }
510
511 #[test]
514 fn test_adam_to_sgd_no_transfer() {
515 let config = SurgeryConfig {
516 from: OptimizerKind::Adam,
517 to: OptimizerKind::SGD,
518 transfer_momentum: false,
519 reset_step: false,
520 momentum_scale: 1.0,
521 };
522 let surgeon = OptimizerSurgeon::new(config);
523 let states = single_map(make_adam_state("w", 5, 3));
524 let result = surgeon.migrate(&states).expect("migrate");
525 let dst = result.get("w").expect("w");
526 assert!(dst.momentum.is_none());
527 assert!(dst.first_moment.is_none());
528 assert!(dst.second_moment.is_none());
529 }
530
531 #[test]
534 fn test_adam_to_sgd_momentum_scale() {
535 let config = SurgeryConfig {
536 from: OptimizerKind::Adam,
537 to: OptimizerKind::SGD,
538 transfer_momentum: true,
539 reset_step: false,
540 momentum_scale: 0.5,
541 };
542 let surgeon = OptimizerSurgeon::new(config);
543 let states = single_map(make_adam_state("w", 10, 2));
544 let result = surgeon.migrate(&states).expect("migrate");
545 let dst = result.get("w").expect("w");
546 let mom = dst.momentum.as_ref().expect("momentum");
547 assert!((mom[0] - 0.05).abs() < 1e-10);
549 }
550
551 #[test]
554 fn test_adam_to_lion_transfer() {
555 let config = SurgeryConfig {
556 from: OptimizerKind::Adam,
557 to: OptimizerKind::Lion,
558 transfer_momentum: true,
559 reset_step: false,
560 momentum_scale: 1.0,
561 };
562 let surgeon = OptimizerSurgeon::new(config);
563 let states = single_map(make_adam_state("w", 8, 3));
564 let result = surgeon.migrate(&states).expect("migrate");
565 let dst = result.get("w").expect("w");
566 assert!(dst.ema.is_some());
567 let ema = dst.ema.as_ref().expect("ema");
568 assert!((ema[0] - 0.1).abs() < 1e-10);
569 assert!(dst.second_moment.is_none());
570 assert!(dst.first_moment.is_none());
571 }
572
573 #[test]
576 fn test_adam_to_lion_momentum_scale() {
577 let config = SurgeryConfig {
578 from: OptimizerKind::Adam,
579 to: OptimizerKind::Lion,
580 transfer_momentum: true,
581 reset_step: false,
582 momentum_scale: 0.9,
583 };
584 let surgeon = OptimizerSurgeon::new(config);
585 let states = single_map(make_adam_state("w", 3, 1));
586 let result = surgeon.migrate(&states).expect("migrate");
587 let dst = result.get("w").expect("w");
588 let ema = dst.ema.as_ref().expect("ema");
589 assert!((ema[0] - 0.09).abs() < 1e-10);
590 }
591
592 #[test]
595 fn test_adamw_to_adam_preserve_moments() {
596 let config = SurgeryConfig {
597 from: OptimizerKind::AdamW,
598 to: OptimizerKind::Adam,
599 transfer_momentum: true,
600 reset_step: false,
601 momentum_scale: 1.0,
602 };
603 let surgeon = OptimizerSurgeon::new(config);
604 let mut src = make_adam_state("w", 20, 4);
605 src.second_moment = Some(vec![0.02; 4]);
606 let states = single_map(src);
607 let result = surgeon.migrate(&states).expect("migrate");
608 let dst = result.get("w").expect("w");
609 assert!(dst.first_moment.is_some());
610 let fm = dst.first_moment.as_ref().expect("first_moment");
611 assert!((fm[0] - 0.1).abs() < 1e-10);
612 assert!(dst.second_moment.is_some());
613 let sm = dst.second_moment.as_ref().expect("second_moment");
614 assert!((sm[0] - 0.02).abs() < 1e-10);
615 }
616
617 #[test]
620 fn test_sgd_to_adam_transfer() {
621 let config = SurgeryConfig {
622 from: OptimizerKind::SGD,
623 to: OptimizerKind::Adam,
624 transfer_momentum: true,
625 reset_step: false,
626 momentum_scale: 1.0,
627 };
628 let surgeon = OptimizerSurgeon::new(config);
629 let states = single_map(make_sgd_state("w", 15, 3));
630 let result = surgeon.migrate(&states).expect("migrate");
631 let dst = result.get("w").expect("w");
632 assert!(dst.first_moment.is_some());
633 let fm = dst.first_moment.as_ref().expect("first_moment");
634 assert!((fm[0] - 0.2).abs() < 1e-10);
635 assert!(dst.second_moment.is_none());
637 }
638
639 #[test]
642 fn test_reset_step_flag() {
643 let config = SurgeryConfig {
644 from: OptimizerKind::Adam,
645 to: OptimizerKind::SGD,
646 transfer_momentum: true,
647 reset_step: true,
648 momentum_scale: 1.0,
649 };
650 let surgeon = OptimizerSurgeon::new(config);
651 let states = single_map(make_adam_state("w", 100, 2));
652 let result = surgeon.migrate(&states).expect("migrate");
653 let dst = result.get("w").expect("w");
654 assert_eq!(dst.step, 0, "step should be reset to 0");
655 }
656
657 #[test]
660 fn test_preserve_step_flag() {
661 let config = SurgeryConfig {
662 from: OptimizerKind::Adam,
663 to: OptimizerKind::SGD,
664 transfer_momentum: true,
665 reset_step: false,
666 momentum_scale: 1.0,
667 };
668 let surgeon = OptimizerSurgeon::new(config);
669 let states = single_map(make_adam_state("w", 42, 2));
670 let result = surgeon.migrate(&states).expect("migrate");
671 let dst = result.get("w").expect("w");
672 assert_eq!(dst.step, 42, "step should be preserved");
673 }
674
675 #[test]
678 fn test_validate_empty_states_error() {
679 let config = SurgeryConfig::default();
680 let surgeon = OptimizerSurgeon::new(config);
681 let empty: HashMap<String, ParamStateSnapshot> = HashMap::new();
682 let err = surgeon.validate(&empty).expect_err("should error on empty");
683 assert!(matches!(err, SurgeryError::EmptyStates));
684 }
685
686 #[test]
689 fn test_migrate_empty_states_error() {
690 let config = SurgeryConfig::default();
691 let surgeon = OptimizerSurgeon::new(config);
692 let empty: HashMap<String, ParamStateSnapshot> = HashMap::new();
693 let err = surgeon.migrate(&empty).expect_err("should error");
694 assert!(matches!(err, SurgeryError::EmptyStates));
695 }
696
697 #[test]
700 fn test_validate_incompatible_missing_first_moment() {
701 let config = SurgeryConfig {
702 from: OptimizerKind::Adam,
703 to: OptimizerKind::SGD,
704 transfer_momentum: true,
705 reset_step: false,
706 momentum_scale: 1.0,
707 };
708 let surgeon = OptimizerSurgeon::new(config);
709 let state = ParamStateSnapshot::new("w"); let states = single_map(state);
712 let err = surgeon.validate(&states).expect_err("should error");
713 assert!(matches!(err, SurgeryError::IncompatibleState(_)));
714 }
715
716 #[test]
719 fn test_validate_ok_no_transfer_no_first_moment() {
720 let config = SurgeryConfig {
721 from: OptimizerKind::Adam,
722 to: OptimizerKind::SGD,
723 transfer_momentum: false,
724 reset_step: false,
725 momentum_scale: 1.0,
726 };
727 let surgeon = OptimizerSurgeon::new(config);
728 let state = ParamStateSnapshot::new("w"); let states = single_map(state);
730 assert!(surgeon.validate(&states).is_ok());
731 }
732
733 #[test]
736 fn test_migration_report_display() {
737 let config = SurgeryConfig {
738 from: OptimizerKind::Adam,
739 to: OptimizerKind::SGD,
740 transfer_momentum: true,
741 reset_step: false,
742 momentum_scale: 1.0,
743 };
744 let surgeon = OptimizerSurgeon::new(config);
745 let before = single_map(make_adam_state("w", 10, 3));
746 let after = surgeon.migrate(&before).expect("migrate");
747 let report = surgeon.migration_report(&before, &after);
748 let s = format!("{}", report);
749 assert!(s.contains("Adam"));
750 assert!(s.contains("SGD"));
751 assert!(s.contains("params_migrated: 1"));
752 }
753
754 #[test]
757 fn test_migration_report_steps_reset() {
758 let config = SurgeryConfig {
759 from: OptimizerKind::Adam,
760 to: OptimizerKind::SGD,
761 transfer_momentum: true,
762 reset_step: true,
763 momentum_scale: 1.0,
764 };
765 let surgeon = OptimizerSurgeon::new(config);
766 let before = single_map(make_adam_state("w", 50, 2));
767 let after = surgeon.migrate(&before).expect("migrate");
768 let report = surgeon.migration_report(&before, &after);
769 assert_eq!(report.steps_reset, 1);
770 assert_eq!(report.steps_preserved, 0);
771 }
772
773 #[test]
776 fn test_multiple_params_migrated() {
777 let config = SurgeryConfig {
778 from: OptimizerKind::Adam,
779 to: OptimizerKind::SGD,
780 transfer_momentum: true,
781 reset_step: false,
782 momentum_scale: 1.0,
783 };
784 let surgeon = OptimizerSurgeon::new(config);
785 let mut states = HashMap::new();
786 for i in 0..5 {
787 let s = make_adam_state(&format!("layer.{}", i), (i as u64) + 1, 4);
788 states.insert(s.param_name.clone(), s);
789 }
790 let result = surgeon.migrate(&states).expect("migrate");
791 assert_eq!(result.len(), 5);
792 for (_, dst) in result.iter() {
793 assert!(dst.momentum.is_some());
794 }
795 }
796
797 #[test]
800 fn test_lion_to_sgd_transfer() {
801 let config = SurgeryConfig {
802 from: OptimizerKind::Lion,
803 to: OptimizerKind::SGD,
804 transfer_momentum: true,
805 reset_step: false,
806 momentum_scale: 1.0,
807 };
808 let surgeon = OptimizerSurgeon::new(config);
809 let states = single_map(make_lion_state("w", 7, 3));
810 let result = surgeon.migrate(&states).expect("migrate");
811 let dst = result.get("w").expect("w");
812 assert!(dst.momentum.is_some());
813 let mom = dst.momentum.as_ref().expect("momentum");
814 assert!((mom[0] - 0.05).abs() < 1e-10);
815 assert!(dst.ema.is_none());
816 }
817
818 #[test]
821 fn test_adamw_to_adam_round_trip() {
822 let config_fwd = SurgeryConfig {
823 from: OptimizerKind::AdamW,
824 to: OptimizerKind::Adam,
825 transfer_momentum: true,
826 reset_step: false,
827 momentum_scale: 1.0,
828 };
829 let config_bwd = SurgeryConfig {
830 from: OptimizerKind::Adam,
831 to: OptimizerKind::AdamW,
832 transfer_momentum: true,
833 reset_step: false,
834 momentum_scale: 1.0,
835 };
836 let mut src = make_adam_state("w", 20, 3);
837 src.second_moment = Some(vec![0.02; 3]);
838 let original_fm = src.first_moment.clone();
839 let original_sm = src.second_moment.clone();
840
841 let states = single_map(src);
842 let mid = OptimizerSurgeon::new(config_fwd).migrate(&states).expect("fwd");
843 let end = OptimizerSurgeon::new(config_bwd).migrate(&mid).expect("bwd");
844
845 let dst = end.get("w").expect("w");
846 assert_eq!(dst.first_moment, original_fm);
847 assert_eq!(dst.second_moment, original_sm);
848 }
849
850 #[test]
853 fn test_surgery_error_display() {
854 let e1 = SurgeryError::InvalidMigration("reason".to_string());
855 assert!(format!("{}", e1).contains("reason"));
856 let e2 = SurgeryError::IncompatibleState("msg".to_string());
857 assert!(format!("{}", e2).contains("msg"));
858 let e3 = SurgeryError::EmptyStates;
859 assert!(format!("{}", e3).contains("Empty"));
860 }
861
862 #[test]
865 fn test_grad_norm_preserved() {
866 let config = SurgeryConfig::default();
867 let surgeon = OptimizerSurgeon::new(config);
868 let mut src = make_adam_state("w", 5, 2);
869 src.grad_norm = Some(std::f64::consts::PI);
870 let states = single_map(src);
871 let result = surgeon.migrate(&states).expect("migrate");
872 let dst = result.get("w").expect("w");
873 assert!((dst.grad_norm.expect("grad_norm") - std::f64::consts::PI).abs() < 1e-10);
874 }
875}