Skip to main content

trustformers_optim/
optimizer_surgery.rs

1//! Optimizer surgery: change optimizer type mid-training while preserving relevant state.
2//!
3//! Supports migrating optimizer state between Adam, AdamW, SGD, Lion, and RMSProp,
4//! transferring momentum buffers and step counts as appropriate.
5
6use std::collections::HashMap;
7
8// ─────────────────────────────────────────── ParamStateSnapshot ──────────────
9
10/// A snapshot of optimizer state for one parameter.
11#[derive(Debug, Clone)]
12pub struct ParamStateSnapshot {
13    pub param_name: String,
14    pub step: u64,
15    /// First moment (m) — Adam exponential moving average of gradient.
16    pub first_moment: Option<Vec<f64>>,
17    /// Second moment (v) — Adam exponential moving average of squared gradient.
18    pub second_moment: Option<Vec<f64>>,
19    /// SGD momentum buffer.
20    pub momentum: Option<Vec<f64>>,
21    /// EMA weights for Lion optimizer.
22    pub ema: Option<Vec<f64>>,
23    /// Last observed gradient norm.
24    pub grad_norm: Option<f64>,
25}
26
27impl ParamStateSnapshot {
28    /// Create a new empty snapshot for the named parameter.
29    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// ─────────────────────────────────────────── OptimizerKind ───────────────────
43
44/// Optimizer type identifier used when specifying migration source/target.
45#[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// ─────────────────────────────────────────── SurgeryConfig ───────────────────
67
68/// Configuration controlling how optimizer state is migrated.
69#[derive(Debug, Clone)]
70pub struct SurgeryConfig {
71    pub from: OptimizerKind,
72    pub to: OptimizerKind,
73    /// When converting Adam→SGD, use first_moment as momentum buffer (true) or reset (false).
74    pub transfer_momentum: bool,
75    /// Reset step counter on switch.
76    pub reset_step: bool,
77    /// Scale transferred momentum by this factor (e.g. 0.9 to dampen).
78    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
93// ─────────────────────────────────────────── MigrationReport ─────────────────
94
95/// Summary report of an optimizer migration operation.
96pub 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// ─────────────────────────────────────────── SurgeryError ────────────────────
125
126/// Errors that can arise during optimizer surgery.
127#[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
137// ─────────────────────────────────────────── OptimizerSurgeon ────────────────
138
139/// Performs optimizer surgery: migrates state from one optimizer type to another.
140pub struct OptimizerSurgeon {
141    config: SurgeryConfig,
142}
143
144impl OptimizerSurgeon {
145    /// Create a new surgeon with the given migration configuration.
146    pub fn new(config: SurgeryConfig) -> Self {
147        Self { config }
148    }
149
150    /// Validate that source states are compatible with this migration config.
151    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        // Check that unsupported migration pairs are rejected.
160        match (&self.config.from, &self.config.to) {
161            // Explicitly supported migrations.
162            (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        // Validate source state compatibility.
190        for (name, state) in states.iter() {
191            match &self.config.from {
192                OptimizerKind::Adam | OptimizerKind::AdamW => {
193                    // Adam/AdamW should have first_moment if transfer_momentum is requested.
194                    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                    // SGD should have momentum buffer if transferring.
203                    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                    // Lion should have ema if transferring.
212                    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                    // RMSProp uses second_moment (v) as its running variance.
221                },
222            }
223        }
224
225        Ok(())
226    }
227
228    /// Migrate state from source optimizer type to target optimizer type.
229    ///
230    /// Returns new state snapshots for use with the target optimizer.
231    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    /// Migrate a single parameter's state.
250    fn migrate_single(&self, src: &ParamStateSnapshot) -> Result<ParamStateSnapshot, SurgeryError> {
251        let mut dst = ParamStateSnapshot::new(src.param_name.clone());
252
253        // Step handling.
254        dst.step = if self.config.reset_step { 0 } else { src.step };
255
256        // Carry over grad_norm always (informational).
257        dst.grad_norm = src.grad_norm;
258
259        match (&self.config.from, &self.config.to) {
260            // ── Adam / AdamW → SGD ────────────────────────────────────────────
261            (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                // second_moment not used by SGD — discard.
269            },
270
271            // ── Adam / AdamW → Lion ───────────────────────────────────────────
272            (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                // second_moment not used by Lion — discard.
280            },
281
282            // ── Adam / AdamW → Adam / AdamW (same family) ─────────────────────
283            (
284                OptimizerKind::Adam | OptimizerKind::AdamW,
285                OptimizerKind::Adam | OptimizerKind::AdamW,
286            ) => {
287                // Preserve both moments (same structure).
288                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            // ── Adam / AdamW → RMSProp ────────────────────────────────────────
298            (OptimizerKind::Adam | OptimizerKind::AdamW, OptimizerKind::RMSProp) => {
299                // RMSProp uses second moment (running variance).
300                dst.second_moment = src.second_moment.clone();
301            },
302
303            // ── SGD → Adam / AdamW ────────────────────────────────────────────
304            (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                // second_moment starts from zero (not available in SGD).
312                dst.second_moment = None;
313            },
314
315            // ── SGD → Lion ────────────────────────────────────────────────────
316            (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            // ── SGD → SGD (pass-through) ──────────────────────────────────────
326            (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            // ── SGD → RMSProp ─────────────────────────────────────────────────
336            (OptimizerKind::SGD, OptimizerKind::RMSProp) => {
337                // No useful state to transfer; second_moment is None.
338            },
339
340            // ── Lion → Adam / AdamW ───────────────────────────────────────────
341            (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            // ── Lion → SGD ────────────────────────────────────────────────────
352            (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            // ── Lion → Lion (pass-through) ────────────────────────────────────
362            (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            // ── Lion → RMSProp ────────────────────────────────────────────────
372            (OptimizerKind::Lion, OptimizerKind::RMSProp) => {
373                // No useful state to transfer.
374            },
375
376            // ── RMSProp → Adam / AdamW ────────────────────────────────────────
377            (OptimizerKind::RMSProp, OptimizerKind::Adam | OptimizerKind::AdamW) => {
378                // RMSProp's running variance maps to second_moment.
379                dst.second_moment = src.second_moment.clone();
380            },
381
382            // ── RMSProp → SGD ─────────────────────────────────────────────────
383            (OptimizerKind::RMSProp, OptimizerKind::SGD) => {
384                // No useful state to transfer.
385            },
386
387            // ── RMSProp → Lion ────────────────────────────────────────────────
388            (OptimizerKind::RMSProp, OptimizerKind::Lion) => {
389                // No useful state to transfer.
390            },
391
392            // ── RMSProp → RMSProp (pass-through) ─────────────────────────────
393            (OptimizerKind::RMSProp, OptimizerKind::RMSProp) => {
394                dst.second_moment = src.second_moment.clone();
395            },
396        }
397
398        Ok(dst)
399    }
400
401    /// Generate a migration summary report comparing before and after snapshots.
402    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            // Count step handling.
417            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            // Count momentum transfer outcome.
424            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// ─────────────────────────────────────────── tests ───────────────────────────
452
453#[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    // ── 1. Adam → SGD with momentum transfer ─────────────────────────────────
487
488    #[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        // second_moment must be cleared
506        assert!(dst.second_moment.is_none());
507        // first_moment must be cleared
508        assert!(dst.first_moment.is_none());
509    }
510
511    // ── 2. Adam → SGD without momentum transfer ───────────────────────────────
512
513    #[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    // ── 3. Adam → SGD with momentum_scale ────────────────────────────────────
532
533    #[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        // 0.1 * 0.5 = 0.05
548        assert!((mom[0] - 0.05).abs() < 1e-10);
549    }
550
551    // ── 4. Adam → Lion ────────────────────────────────────────────────────────
552
553    #[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    // ── 5. Adam → Lion with momentum_scale ───────────────────────────────────
574
575    #[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    // ── 6. AdamW → Adam ───────────────────────────────────────────────────────
593
594    #[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    // ── 7. SGD → Adam ─────────────────────────────────────────────────────────
618
619    #[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        // second_moment must be None (not available in SGD)
636        assert!(dst.second_moment.is_none());
637    }
638
639    // ── 8. reset_step flag ────────────────────────────────────────────────────
640
641    #[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    // ── 9. preserve step flag ─────────────────────────────────────────────────
658
659    #[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    // ── 10. validate: empty states error ──────────────────────────────────────
676
677    #[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    // ── 11. migrate: empty states error ───────────────────────────────────────
687
688    #[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    // ── 12. validate: incompatible state (missing first_moment for Adam→SGD) ──
698
699    #[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        // State missing first_moment
710        let state = ParamStateSnapshot::new("w"); // no first_moment
711        let states = single_map(state);
712        let err = surgeon.validate(&states).expect_err("should error");
713        assert!(matches!(err, SurgeryError::IncompatibleState(_)));
714    }
715
716    // ── 13. validate: ok when transfer_momentum=false even without first_moment
717
718    #[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"); // no first_moment
729        let states = single_map(state);
730        assert!(surgeon.validate(&states).is_ok());
731    }
732
733    // ── 14. migration_report display ──────────────────────────────────────────
734
735    #[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    // ── 15. migration_report stats: steps reset ───────────────────────────────
755
756    #[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    // ── 16. multiple params migrated ─────────────────────────────────────────
774
775    #[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    // ── 17. Lion → SGD transfer ───────────────────────────────────────────────
798
799    #[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    // ── 18. AdamW → Adam → AdamW round-trip preserves moments ────────────────
819
820    #[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    // ── 19. SurgeryError display ──────────────────────────────────────────────
851
852    #[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    // ── 20. grad_norm preserved through migration ─────────────────────────────
863
864    #[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}