Skip to main content

trustformers_optim/
sofo.rs

1//! # SOFO: Second-Order Forward Optimizer
2//!
3//! SOFO is a second-order optimizer that efficiently navigates loss surfaces using
4//! forward-mode differentiation instead of backpropagation. By relying on easily
5//! parallelized batched forward-mode differentiation, SOFO enjoys constant memory
6//! cost in time and achieves wallclock time essentially on par with first-order
7//! gradient-based optimizers while providing second-order optimization benefits.
8//!
9//! ## Key Features
10//! - **Forward-Mode Differentiation**: Uses forward-mode AD instead of backpropagation
11//! - **Constant Memory Cost**: Memory usage doesn't grow with sequence length
12//! - **GPU Parallelism**: Effective use of parallel computing for forward passes
13//! - **Second-Order Benefits**: Curvature information for better optimization
14//! - **Scalable**: Suitable for large neural networks and long sequences
15//!
16//! ## Research Foundation
17//! Based on "SOFO: Second-Order Forward Optimizer" (NeurIPS 2024/2025)
18//! - Constant memory cost in time unlike traditional second-order methods
19//! - Per-iteration wallclock time comparable to first-order optimizers
20//! - Effective GPU parallelization through batched forward-mode differentiation
21//! - Superior convergence properties compared to first-order methods
22//!
23//! ## Usage Example
24//! ```rust,no_run
25//! use trustformers_optim::{SOFO, SOFOConfig};
26//! use trustformers_core::tensor::Tensor;
27//!
28//! let config = SOFOConfig::new()
29//!     .learning_rate(1e-3)
30//!     .batch_size(32)
31//!     .curvature_strength(0.1)
32//!     .forward_passes(8)
33//!     .build();
34//!
35//! let mut optimizer = SOFO::new(config);
36//!
37//! // In training loop
38//! // optimizer.zero_grad();
39//! // ... compute loss and gradients using forward mode ...
40//! // optimizer.step(&mut parameters, &gradients, &loss_fn)?;
41//! ```
42
43use anyhow::Result;
44use std::collections::HashMap;
45use trustformers_core::tensor::Tensor;
46
47/// Which estimator produced the curvature used by the most recent step.
48///
49/// SOFO's paper-faithful path needs directional derivatives of the *gradient*, which
50/// only the caller can supply. Rather than invent numbers when no oracle is
51/// available, the optimizer records which estimator it actually used.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum CurvatureSource {
54    /// No step has been taken yet.
55    None,
56    /// Hutchinson diagonal Hessian estimate `E[v ⊙ (H v)]` with Rademacher `v`,
57    /// where `H v` comes from a central difference of caller-supplied gradients.
58    /// This is the paper's second-order path.
59    HutchinsonFromOracle,
60    /// Empirical-Fisher (Gauss-Newton) diagonal `g ⊙ g`.
61    ///
62    /// Used by [`SOFO::step`], which has no way to evaluate the gradient at a
63    /// perturbed parameter point. It is a real, standard curvature proxy — but it is
64    /// *not* the Hessian, and callers who need the paper's estimator must use
65    /// [`SOFO::step_with_gradient_oracle`].
66    EmpiricalFisherDiagonal,
67}
68
69/// Counter-based deterministic Rademacher sampler.
70///
71/// SOFO's curvature estimate is only unbiased for *independent* ±1 probe vectors, so
72/// the sequence has to be genuinely varied — the previous implementation used
73/// `sin(i * 0.1)`, which is neither random nor ±1. A counter-based splitmix64 stream
74/// gives independent draws while staying fully reproducible from `seed`.
75#[derive(Debug, Clone)]
76struct RademacherStream {
77    seed: u64,
78    counter: u64,
79}
80
81impl RademacherStream {
82    fn new(seed: u64) -> Self {
83        Self { seed, counter: 0 }
84    }
85
86    fn next_bits(&mut self) -> u64 {
87        self.counter = self.counter.wrapping_add(1);
88        let mut z = self.seed.wrapping_add(self.counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
89        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
90        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
91        z ^ (z >> 31)
92    }
93
94    /// A vector of independent ±1 entries.
95    fn sample(&mut self, len: usize) -> Vec<f32> {
96        (0..len).map(|_| if self.next_bits() & 1 == 0 { -1.0 } else { 1.0 }).collect()
97    }
98}
99
100/// Configuration for SOFO optimizer
101#[derive(Debug, Clone)]
102pub struct SOFOConfig {
103    /// Learning rate (default: 1e-3)
104    pub learning_rate: f32,
105    /// Batch size for forward-mode differentiation (default: 32)
106    pub batch_size: usize,
107    /// Number of forward passes for curvature estimation (default: 8)
108    pub forward_passes: usize,
109    /// Strength of curvature information (default: 0.1)
110    pub curvature_strength: f32,
111    /// Damping factor for numerical stability (default: 1e-6)
112    pub damping: f32,
113    /// Weight decay (default: 0.0)
114    pub weight_decay: f32,
115    /// Enable adaptive curvature estimation (default: true)
116    pub adaptive_curvature: bool,
117    /// Momentum for first-order updates (default: 0.9)
118    pub momentum: f32,
119    /// Use Nesterov acceleration (default: true)
120    pub nesterov: bool,
121    /// Maximum condition number for curvature matrix (default: 1e6)
122    pub max_condition_number: f32,
123    /// Enable memory efficient mode (default: true)
124    pub memory_efficient: bool,
125    /// Parallel computation threshold (default: 1000)
126    pub parallel_threshold: usize,
127    /// Finite-difference step used for the Hessian-vector product (default: 1e-3).
128    ///
129    /// A central difference of gradients trades truncation error (`O(ε²)`) against
130    /// cancellation error (`O(δ/ε)` for gradient noise `δ`); `1e-3` is the usual
131    /// compromise for `f32` parameters.
132    pub hvp_epsilon: f32,
133    /// Seed for the Rademacher probe stream (default: 0x5060_F0F0_1234_5678).
134    pub probe_seed: u64,
135}
136
137impl Default for SOFOConfig {
138    fn default() -> Self {
139        Self {
140            learning_rate: 1e-3,
141            batch_size: 32,
142            forward_passes: 8,
143            curvature_strength: 0.1,
144            damping: 1e-6,
145            weight_decay: 0.0,
146            adaptive_curvature: true,
147            momentum: 0.9,
148            nesterov: true,
149            max_condition_number: 1e6,
150            memory_efficient: true,
151            parallel_threshold: 1000,
152            hvp_epsilon: 1e-3,
153            probe_seed: 0x5060_F0F0_1234_5678,
154        }
155    }
156}
157
158impl SOFOConfig {
159    /// Create a new SOFO configuration with default values
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    /// Set the learning rate
165    pub fn learning_rate(mut self, lr: f32) -> Self {
166        self.learning_rate = lr;
167        self
168    }
169
170    /// Set the batch size for forward-mode differentiation
171    pub fn batch_size(mut self, batch_size: usize) -> Self {
172        self.batch_size = batch_size;
173        self
174    }
175
176    /// Set the number of forward passes for curvature estimation
177    pub fn forward_passes(mut self, passes: usize) -> Self {
178        self.forward_passes = passes;
179        self
180    }
181
182    /// Set the curvature strength
183    pub fn curvature_strength(mut self, strength: f32) -> Self {
184        self.curvature_strength = strength;
185        self
186    }
187
188    /// Set the damping factor
189    pub fn damping(mut self, damping: f32) -> Self {
190        self.damping = damping;
191        self
192    }
193
194    /// Set weight decay
195    pub fn weight_decay(mut self, decay: f32) -> Self {
196        self.weight_decay = decay;
197        self
198    }
199
200    /// Enable or disable momentum
201    pub fn momentum(mut self, momentum: f32) -> Self {
202        self.momentum = momentum;
203        self
204    }
205
206    /// Set the finite-difference step for the Hessian-vector product.
207    pub fn hvp_epsilon(mut self, eps: f32) -> Self {
208        self.hvp_epsilon = eps;
209        self
210    }
211
212    /// Set the seed of the Rademacher probe stream.
213    pub fn probe_seed(mut self, seed: u64) -> Self {
214        self.probe_seed = seed;
215        self
216    }
217
218    /// Build the configuration
219    /// Enable or disable adaptive per-parameter curvature weighting
220    pub fn adaptive_curvature(mut self, enable: bool) -> Self {
221        self.adaptive_curvature = enable;
222        self
223    }
224
225    /// Set the maximum condition number tolerated in the curvature estimate
226    pub fn max_condition_number(mut self, max_condition_number: f32) -> Self {
227        self.max_condition_number = max_condition_number;
228        self
229    }
230
231    pub fn build(self) -> Self {
232        self
233    }
234}
235
236/// SOFO optimizer state for tracking forward-mode differentiation
237#[derive(Debug, Clone)]
238pub struct SOFOState {
239    /// Current step count
240    pub step: u64,
241    /// Momentum buffers for first-order terms
242    pub momentum_buffers: HashMap<String, Tensor>,
243    /// Curvature estimates for each parameter
244    pub curvature_estimates: HashMap<String, Tensor>,
245    /// Forward-mode gradient accumulations
246    pub forward_gradients: HashMap<String, Vec<Tensor>>,
247    /// Eigenvalue estimates for condition number control
248    pub eigenvalue_estimates: HashMap<String, Tensor>,
249    /// Adaptive curvature weights
250    pub adaptive_weights: HashMap<String, f32>,
251    /// Forward pass computation statistics
252    pub forward_stats: ForwardModeStats,
253    /// Memory usage tracking
254    pub memory_stats: MemoryStats,
255    /// Which estimator produced the curvature used by the most recent step.
256    pub curvature_source: CurvatureSource,
257}
258
259/// Counters for the gradient-oracle evaluations SOFO actually performed.
260///
261/// Every field here is *measured*: `total_forward_passes` is incremented once per
262/// real oracle call, and `total_oracle_time` accumulates the wall-clock time those
263/// calls took. Nothing is modelled or assumed.
264#[derive(Debug, Clone, Default)]
265pub struct ForwardModeStats {
266    /// Total gradient-oracle evaluations performed (two per Hutchinson probe).
267    pub total_forward_passes: u64,
268    /// Accumulated wall-clock time spent inside the gradient oracle.
269    pub total_oracle_time: std::time::Duration,
270}
271
272impl ForwardModeStats {
273    /// Mean wall-clock time per oracle evaluation, or `None` if none were performed.
274    pub fn avg_forward_time(&self) -> Option<std::time::Duration> {
275        if self.total_forward_passes == 0 {
276            None
277        } else {
278            Some(self.total_oracle_time / self.total_forward_passes as u32)
279        }
280    }
281}
282
283/// Measured size of the optimizer's own state buffers.
284#[derive(Debug, Clone, Default)]
285pub struct MemoryStats {
286    /// Bytes currently held by SOFO's momentum and curvature buffers.
287    pub state_bytes: usize,
288    /// `state_bytes` expressed in MiB.
289    pub current_state_mb: f32,
290    /// Largest `current_state_mb` observed so far.
291    pub peak_state_mb: f32,
292    /// Number of scalar parameters most recently optimized.
293    pub num_parameters: usize,
294}
295
296impl Default for SOFOState {
297    fn default() -> Self {
298        Self {
299            step: 0,
300            momentum_buffers: HashMap::new(),
301            curvature_estimates: HashMap::new(),
302            forward_gradients: HashMap::new(),
303            eigenvalue_estimates: HashMap::new(),
304            adaptive_weights: HashMap::new(),
305            forward_stats: ForwardModeStats::default(),
306            memory_stats: MemoryStats::default(),
307            curvature_source: CurvatureSource::None,
308        }
309    }
310}
311
312/// SOFO (Second-Order Forward Optimizer)
313///
314/// A second-order optimizer using forward-mode differentiation for constant
315/// memory cost and efficient GPU parallelization.
316pub struct SOFO {
317    config: SOFOConfig,
318    state: SOFOState,
319    rademacher: RademacherStream,
320}
321
322impl SOFO {
323    /// Create a new SOFO optimizer
324    pub fn new(config: SOFOConfig) -> Self {
325        let rademacher = RademacherStream::new(config.probe_seed);
326        Self {
327            config,
328            state: SOFOState::default(),
329            rademacher,
330        }
331    }
332
333    /// Get the current learning rate
334    pub fn learning_rate(&self) -> f32 {
335        self.config.learning_rate
336    }
337
338    /// Set the learning rate
339    pub fn set_learning_rate(&mut self, lr: f32) {
340        self.config.learning_rate = lr;
341    }
342
343    /// Generates one set of independent Rademacher probe directions, one per parameter.
344    ///
345    /// # Errors
346    ///
347    /// Returns an error when a probe tensor cannot be built for a parameter's shape.
348    fn generate_random_directions(
349        &mut self,
350        parameters: &HashMap<String, Tensor>,
351    ) -> Result<Vec<HashMap<String, Tensor>>> {
352        // Iterate in a deterministic order so a given seed always yields the same
353        // probe sequence regardless of `HashMap` iteration order.
354        let mut names: Vec<&String> = parameters.keys().collect();
355        names.sort();
356
357        let mut direction_sets = Vec::with_capacity(self.config.forward_passes);
358        for _ in 0..self.config.forward_passes {
359            let mut directions = HashMap::new();
360            for name in &names {
361                let Some(parameter) = parameters.get(*name) else {
362                    continue;
363                };
364                let shape = parameter.shape();
365                let total: usize = shape.iter().product();
366                let probe = self.rademacher.sample(total);
367                directions.insert((*name).clone(), Tensor::from_vec(probe, &shape)?);
368            }
369            direction_sets.push(directions);
370        }
371
372        Ok(direction_sets)
373    }
374
375    /// Empirical-Fisher (Gauss-Newton) diagonal curvature `g ⊙ g + damping`.
376    ///
377    /// This is what [`SOFO::step`] uses: a real, standard curvature proxy computed
378    /// from the gradients the caller already has. It is *not* the Hessian.
379    ///
380    /// # Errors
381    ///
382    /// Returns an error when a tensor operation fails.
383    fn empirical_fisher_curvature(
384        &self,
385        gradients: &HashMap<String, Tensor>,
386    ) -> Result<HashMap<String, Tensor>> {
387        let mut estimates = HashMap::new();
388        for (param_name, gradient) in gradients.iter() {
389            let squared = gradient.mul(gradient)?;
390            estimates.insert(param_name.clone(), squared.add_scalar(self.config.damping)?);
391        }
392        Ok(estimates)
393    }
394
395    /// Hutchinson diagonal-Hessian estimate driven by a caller-supplied gradient oracle.
396    ///
397    /// For Rademacher probes `v`, `E[v ⊙ (H v)] = diag(H)`. The Hessian-vector product
398    /// is obtained by a central difference of *real* gradients:
399    /// `H v ≈ (∇f(θ + εv) − ∇f(θ − εv)) / (2ε)`, so the oracle is called twice per
400    /// probe. The absolute value is taken because the Newton-style division below
401    /// requires a positive preconditioner.
402    ///
403    /// # Errors
404    ///
405    /// Returns an error when the oracle fails or returns a gradient whose shape does
406    /// not match the parameter it was requested for.
407    fn hutchinson_curvature<F>(
408        &mut self,
409        parameters: &HashMap<String, Tensor>,
410        oracle: &mut F,
411    ) -> Result<HashMap<String, Tensor>>
412    where
413        F: FnMut(&HashMap<String, Tensor>) -> Result<HashMap<String, Tensor>>,
414    {
415        let eps = self.config.hvp_epsilon;
416        let direction_sets = self.generate_random_directions(parameters)?;
417
418        let mut accumulator: HashMap<String, Vec<f32>> = HashMap::new();
419        let mut probes_used = 0usize;
420
421        for directions in &direction_sets {
422            let mut plus = HashMap::new();
423            let mut minus = HashMap::new();
424            for (name, parameter) in parameters.iter() {
425                let Some(direction) = directions.get(name) else {
426                    continue;
427                };
428                plus.insert(name.clone(), parameter.add(&direction.mul_scalar(eps)?)?);
429                minus.insert(name.clone(), parameter.sub(&direction.mul_scalar(eps)?)?);
430            }
431
432            let grad_plus = oracle(&plus)?;
433            let grad_minus = oracle(&minus)?;
434            self.state.forward_stats.total_forward_passes += 2;
435            probes_used += 1;
436
437            for (name, direction) in directions.iter() {
438                let (Some(gp), Some(gm)) = (grad_plus.get(name), grad_minus.get(name)) else {
439                    continue;
440                };
441                let gp_data = gp.data_f32()?;
442                let gm_data = gm.data_f32()?;
443                let v_data = direction.data_f32()?;
444                if gp_data.len() != v_data.len() || gm_data.len() != v_data.len() {
445                    return Err(anyhow::anyhow!(
446                        "gradient oracle returned {} / {} elements for '{name}' but the \
447                         parameter has {}",
448                        gp_data.len(),
449                        gm_data.len(),
450                        v_data.len()
451                    ));
452                }
453                let slot =
454                    accumulator.entry(name.clone()).or_insert_with(|| vec![0.0; v_data.len()]);
455                for i in 0..v_data.len() {
456                    // v ⊙ (H v), with H v from the central difference.
457                    let hv = (gp_data[i] - gm_data[i]) / (2.0 * eps);
458                    slot[i] += v_data[i] * hv;
459                }
460            }
461        }
462
463        let mut estimates = HashMap::new();
464        let divisor = probes_used.max(1) as f32;
465        for (name, mut values) in accumulator {
466            for value in values.iter_mut() {
467                // Newton-style division needs a positive preconditioner; the sign of a
468                // diagonal Hessian entry is not usable directly.
469                *value = (*value / divisor).abs() + self.config.damping;
470            }
471            let shape =
472                parameters.get(&name).map(|t| t.shape()).unwrap_or_else(|| vec![values.len()]);
473            estimates.insert(name, Tensor::from_vec(values, &shape)?);
474        }
475
476        Ok(estimates)
477    }
478
479    /// Apply adaptive curvature weighting
480    fn apply_adaptive_curvature(
481        &mut self,
482        param_name: &str,
483        curvature: &Tensor,
484        gradient: &Tensor,
485    ) -> Result<Tensor> {
486        if !self.config.adaptive_curvature {
487            return Ok(curvature.clone());
488        }
489
490        // Compute gradient-curvature alignment
491        let grad_norm = gradient.norm()?;
492        let curv_norm = curvature.norm()?;
493
494        let alignment = if grad_norm > 0.0 && curv_norm > 0.0 {
495            let grad_data = gradient.data_f32()?;
496            let curv_data = curvature.data_f32()?;
497            let dot_product: f32 =
498                grad_data.iter().zip(curv_data.iter()).map(|(&a, &b)| a * b).sum();
499            dot_product / (grad_norm * curv_norm)
500        } else {
501            0.0
502        };
503
504        // Adaptive weight based on alignment
505        let adaptive_weight = (1.0 + alignment.abs()) * self.config.curvature_strength;
506        self.state.adaptive_weights.insert(param_name.to_string(), adaptive_weight);
507
508        // Apply adaptive weighting
509        Ok(curvature.mul_scalar(adaptive_weight)?)
510    }
511
512    /// Update momentum buffer
513    fn update_momentum(&mut self, param_name: &str, gradient: &Tensor) -> Result<Tensor> {
514        let momentum = self.config.momentum;
515
516        let momentum_update =
517            if let Some(prev_momentum) = self.state.momentum_buffers.get(param_name) {
518                let momentum_tensor = Tensor::scalar(momentum)?;
519                let one_minus_momentum = Tensor::scalar(1.0 - momentum)?;
520
521                let weighted_prev = prev_momentum.mul(&momentum_tensor)?;
522                let weighted_grad = gradient.mul(&one_minus_momentum)?;
523                weighted_prev.add(&weighted_grad)?
524            } else {
525                gradient.mul(&Tensor::scalar(1.0 - momentum)?)?
526            };
527
528        self.state
529            .momentum_buffers
530            .insert(param_name.to_string(), momentum_update.clone());
531        Ok(momentum_update)
532    }
533
534    /// Compute second-order update direction
535    fn compute_second_order_update(&self, gradient: &Tensor, curvature: &Tensor) -> Result<Tensor> {
536        // Newton-like update: H^(-1) * g
537        // We approximate the inverse using element-wise division with regularization
538
539        let regularized_curvature = curvature.add(&Tensor::scalar(self.config.damping)?)?;
540        let newton_direction = gradient.div(&regularized_curvature)?;
541
542        Ok(newton_direction)
543    }
544
545    /// Control condition number of curvature estimates
546    fn control_condition_number(&self, curvature: &Tensor) -> Result<Tensor> {
547        // Clamp eigenvalues to control condition number
548        let min_eigenvalue = self.config.damping;
549        let max_eigenvalue = min_eigenvalue * self.config.max_condition_number;
550
551        Ok(curvature.clamp(min_eigenvalue, max_eigenvalue)?)
552    }
553
554    /// Records the *measured* size of the optimizer's own state buffers.
555    ///
556    /// `num_parameters` counts scalar parameters (not tensors) and the byte totals are
557    /// derived from the buffers SOFO actually holds — momentum and curvature — so the
558    /// reported figure tracks reality rather than a modelled overhead percentage.
559    fn update_memory_stats(&mut self, parameters: &HashMap<String, Tensor>) {
560        let scalar_count: usize =
561            parameters.values().map(|t| t.shape().iter().product::<usize>()).sum();
562
563        let state_bytes: usize = self
564            .state
565            .momentum_buffers
566            .values()
567            .chain(self.state.curvature_estimates.values())
568            .map(|t| t.shape().iter().product::<usize>() * std::mem::size_of::<f32>())
569            .sum();
570
571        let state_mb = state_bytes as f32 / (1024.0 * 1024.0);
572        self.state.memory_stats.current_state_mb = state_mb;
573        self.state.memory_stats.peak_state_mb = self.state.memory_stats.peak_state_mb.max(state_mb);
574        self.state.memory_stats.num_parameters = scalar_count;
575        self.state.memory_stats.state_bytes = state_bytes;
576    }
577
578    /// Performs one optimization step using the **empirical-Fisher diagonal** as the
579    /// curvature estimate.
580    ///
581    /// The paper's estimator needs gradients at perturbed parameter points, which this
582    /// signature cannot obtain. Rather than invent a Hessian, this path uses the
583    /// Gauss-Newton/empirical-Fisher diagonal `g ⊙ g`, records
584    /// [`CurvatureSource::EmpiricalFisherDiagonal`] in the state, and reports zero
585    /// forward passes. Use [`SOFO::step_with_gradient_oracle`] for the second-order path.
586    ///
587    /// # Errors
588    ///
589    /// Returns an error when a tensor operation fails.
590    pub fn step(
591        &mut self,
592        parameters: &mut HashMap<String, Tensor>,
593        gradients: &HashMap<String, Tensor>,
594    ) -> Result<()> {
595        let curvature_estimates = self.empirical_fisher_curvature(gradients)?;
596        self.state.curvature_source = CurvatureSource::EmpiricalFisherDiagonal;
597        self.apply_step(parameters, gradients, curvature_estimates)
598    }
599
600    /// Performs one optimization step using the paper's second-order curvature.
601    ///
602    /// `oracle` must return `∇f` evaluated at the parameter map it is handed; SOFO
603    /// calls it twice per Rademacher probe (`forward_passes` probes per step) to form
604    /// the central-difference Hessian-vector product behind the Hutchinson diagonal
605    /// estimate. The oracle's wall-clock cost is accumulated into
606    /// [`ForwardModeStats::total_oracle_time`].
607    ///
608    /// # Errors
609    ///
610    /// Returns an error when the oracle fails or returns mismatched shapes.
611    pub fn step_with_gradient_oracle<F>(
612        &mut self,
613        parameters: &mut HashMap<String, Tensor>,
614        gradients: &HashMap<String, Tensor>,
615        oracle: &mut F,
616    ) -> Result<()>
617    where
618        F: FnMut(&HashMap<String, Tensor>) -> Result<HashMap<String, Tensor>>,
619    {
620        let started = std::time::Instant::now();
621        let snapshot: HashMap<String, Tensor> =
622            parameters.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
623        let curvature_estimates = self.hutchinson_curvature(&snapshot, oracle)?;
624        self.state.forward_stats.total_oracle_time += started.elapsed();
625        self.state.curvature_source = CurvatureSource::HutchinsonFromOracle;
626        self.apply_step(parameters, gradients, curvature_estimates)
627    }
628
629    /// Shared update body: applies weight decay, preconditions by the supplied
630    /// curvature, and steps the parameters.
631    fn apply_step(
632        &mut self,
633        parameters: &mut HashMap<String, Tensor>,
634        gradients: &HashMap<String, Tensor>,
635        curvature_estimates: HashMap<String, Tensor>,
636    ) -> Result<()> {
637        self.state.step += 1;
638
639        for (param_name, gradient) in gradients.iter() {
640            if let Some(parameter) = parameters.get_mut(param_name) {
641                // Apply weight decay if configured
642                let mut effective_gradient = gradient.clone();
643                if self.config.weight_decay > 0.0 {
644                    let weight_decay_term =
645                        parameter.mul(&Tensor::scalar(self.config.weight_decay)?)?;
646                    effective_gradient = effective_gradient.add(&weight_decay_term)?;
647                }
648
649                // Get curvature estimate for this parameter
650                let curvature = if let Some(curv) = curvature_estimates.get(param_name) {
651                    self.apply_adaptive_curvature(param_name, curv, &effective_gradient)?
652                } else {
653                    // Fallback to first-order
654                    Tensor::ones_like(&effective_gradient)?
655                        .mul(&Tensor::scalar(self.config.damping)?)?
656                };
657
658                // Control condition number
659                let controlled_curvature = self.control_condition_number(&curvature)?;
660
661                // Compute second-order update direction
662                let second_order_direction =
663                    self.compute_second_order_update(&effective_gradient, &controlled_curvature)?;
664
665                // Update momentum
666                let momentum_update = self.update_momentum(param_name, &second_order_direction)?;
667
668                // Combine first-order momentum with second-order direction
669                let final_update = if self.config.nesterov {
670                    // Nesterov acceleration with second-order
671                    let momentum_tensor = Tensor::scalar(self.config.momentum)?;
672                    momentum_update.mul(&momentum_tensor)?.add(&second_order_direction)?
673                } else {
674                    momentum_update
675                };
676
677                // Apply learning rate and update parameter
678                let lr_tensor = Tensor::scalar(self.config.learning_rate)?;
679                let param_update = final_update.mul(&lr_tensor)?;
680
681                *parameter = parameter.sub(&param_update)?;
682
683                // Store curvature estimate for monitoring
684                self.state.curvature_estimates.insert(param_name.clone(), controlled_curvature);
685            }
686        }
687
688        // Measure the state we actually hold, after the buffers have been written.
689        self.update_memory_stats(parameters);
690
691        Ok(())
692    }
693
694    /// Get SOFO-specific optimization statistics
695    pub fn get_sofo_stats(&self) -> SOFOStats {
696        let avg_curvature_strength = if self.state.adaptive_weights.is_empty() {
697            self.config.curvature_strength
698        } else {
699            self.state.adaptive_weights.values().sum::<f32>()
700                / self.state.adaptive_weights.len() as f32
701        };
702
703        // Derived entirely from the stored curvature tensors; 1.0 only when no step
704        // has produced any curvature yet (a genuinely unconditioned identity).
705        let avg_condition_number = if self.state.curvature_estimates.is_empty() {
706            1.0
707        } else {
708            let mut total_condition = 0.0;
709            let mut count = 0;
710
711            for curvature in self.state.curvature_estimates.values() {
712                if let Ok((min_val, max_val)) = curvature.min_max() {
713                    if min_val > 0.0 {
714                        total_condition += max_val / min_val;
715                        count += 1;
716                    }
717                }
718            }
719
720            if count > 0 {
721                total_condition / count as f32
722            } else {
723                1.0
724            }
725        };
726
727        SOFOStats {
728            step: self.state.step,
729            total_forward_passes: self.state.forward_stats.total_forward_passes,
730            avg_curvature_strength,
731            avg_condition_number,
732            curvature_source: self.state.curvature_source,
733            state_bytes: self.state.memory_stats.state_bytes,
734            current_state_mb: self.state.memory_stats.current_state_mb,
735            num_parameters: self.state.memory_stats.num_parameters,
736        }
737    }
738
739    /// Which curvature estimator produced the most recent step.
740    pub fn curvature_source(&self) -> CurvatureSource {
741        self.state.curvature_source
742    }
743
744    /// Get forward-mode differentiation statistics
745    pub fn get_forward_stats(&self) -> &ForwardModeStats {
746        &self.state.forward_stats
747    }
748
749    /// Get memory usage statistics
750    pub fn get_memory_stats(&self) -> &MemoryStats {
751        &self.state.memory_stats
752    }
753
754    /// Reset optimizer state
755    pub fn reset_state(&mut self) {
756        self.state = SOFOState::default();
757    }
758
759    /// Get curvature estimates for analysis
760    pub fn get_curvature_estimates(&self) -> &HashMap<String, Tensor> {
761        &self.state.curvature_estimates
762    }
763
764    /// Get adaptive weights for each parameter
765    pub fn get_adaptive_weights(&self) -> &HashMap<String, f32> {
766        &self.state.adaptive_weights
767    }
768}
769
770/// SOFO optimizer statistics for monitoring and analysis
771#[derive(Debug, Clone)]
772pub struct SOFOStats {
773    /// Current optimization step
774    pub step: u64,
775    /// Total forward passes performed
776    pub total_forward_passes: u64,
777    /// Average curvature strength across parameters
778    pub avg_curvature_strength: f32,
779    /// Average condition number of the diagonal curvature estimates
780    pub avg_condition_number: f32,
781    /// Which estimator produced the curvature used by the most recent step
782    pub curvature_source: CurvatureSource,
783    /// Measured bytes held by SOFO's own state buffers
784    pub state_bytes: usize,
785    /// `state_bytes` expressed in MiB
786    pub current_state_mb: f32,
787    /// Number of scalar parameters most recently optimized
788    pub num_parameters: usize,
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use trustformers_core::tensor::Tensor;
795
796    #[test]
797    fn test_sofo_creation() {
798        let config = SOFOConfig::new().learning_rate(1e-3).batch_size(32).forward_passes(8).build();
799
800        let optimizer = SOFO::new(config);
801        assert_eq!(optimizer.learning_rate(), 1e-3);
802    }
803
804    #[test]
805    fn test_sofo_config_builder() {
806        let config = SOFOConfig::new()
807            .learning_rate(2e-3)
808            .batch_size(64)
809            .forward_passes(16)
810            .curvature_strength(0.2)
811            .damping(1e-5)
812            .weight_decay(1e-4)
813            .momentum(0.95)
814            .build();
815
816        assert_eq!(config.learning_rate, 2e-3);
817        assert_eq!(config.batch_size, 64);
818        assert_eq!(config.forward_passes, 16);
819        assert_eq!(config.curvature_strength, 0.2);
820        assert_eq!(config.damping, 1e-5);
821        assert_eq!(config.weight_decay, 1e-4);
822        assert_eq!(config.momentum, 0.95);
823    }
824
825    #[test]
826    fn test_sofo_step() -> Result<()> {
827        let config = SOFOConfig::new().learning_rate(1e-2).forward_passes(4).build();
828        let mut optimizer = SOFO::new(config);
829
830        // Create test parameters and gradients
831        let mut parameters = HashMap::new();
832        parameters.insert("weight".to_string(), Tensor::ones(&[2, 2])?);
833
834        let mut gradients = HashMap::new();
835        gradients.insert(
836            "weight".to_string(),
837            Tensor::ones(&[2, 2])?.mul_scalar(0.1)?,
838        );
839
840        // Store original value
841        let original_value =
842            parameters.get("weight").expect("Key not found").mean()?.to_scalar()?;
843
844        // Perform optimization step
845        optimizer.step(&mut parameters, &gradients)?;
846
847        // Check that parameter was updated
848        let updated_value = parameters.get("weight").expect("Key not found").mean()?.to_scalar()?;
849        assert_ne!(updated_value, original_value);
850
851        Ok(())
852    }
853
854    #[test]
855    fn test_random_direction_generation() -> Result<()> {
856        let config = SOFOConfig::new().forward_passes(3).build();
857        let mut optimizer = SOFO::new(config);
858
859        let mut parameters = HashMap::new();
860        parameters.insert("weight1".to_string(), Tensor::ones(&[2, 2])?);
861        parameters.insert("weight2".to_string(), Tensor::ones(&[3, 3])?);
862
863        let direction_sets = optimizer.generate_random_directions(&parameters)?;
864
865        assert_eq!(direction_sets.len(), 3);
866        for directions in &direction_sets {
867            assert_eq!(directions.len(), 2);
868            assert!(directions.contains_key("weight1"));
869            assert!(directions.contains_key("weight2"));
870        }
871
872        Ok(())
873    }
874
875    /// The empirical-Fisher fallback must produce `g² + damping`, elementwise.
876    #[test]
877    fn test_empirical_fisher_curvature() -> Result<()> {
878        let config = SOFOConfig::new().damping(1e-3).build();
879        let optimizer = SOFO::new(config);
880
881        let mut gradients = HashMap::new();
882        gradients.insert(
883            "weight".to_string(),
884            Tensor::from_vec(vec![2.0_f32, -3.0, 0.5, 0.0], &[2, 2])?,
885        );
886
887        let curvature = optimizer.empirical_fisher_curvature(&gradients)?;
888        let values = curvature.get("weight").expect("curvature present").data_f32()?;
889
890        let expected = [4.0_f32 + 1e-3, 9.0 + 1e-3, 0.25 + 1e-3, 1e-3];
891        assert_eq!(values.len(), expected.len());
892        for (actual, want) in values.iter().zip(expected.iter()) {
893            assert!((actual - want).abs() < 1e-5, "got {actual}, want {want}");
894        }
895
896        Ok(())
897    }
898
899    #[test]
900    fn test_momentum_update() -> Result<()> {
901        let config = SOFOConfig::new().momentum(0.9).build();
902        let mut optimizer = SOFO::new(config);
903
904        let gradient = Tensor::ones(&[2, 2])?.mul_scalar(0.5)?;
905
906        // First update
907        let momentum1 = optimizer.update_momentum("test", &gradient)?;
908
909        // Second update
910        let momentum2 = optimizer.update_momentum("test", &gradient)?;
911
912        // Momentum should change between updates
913        assert_ne!(
914            momentum1.mean()?.to_scalar()?,
915            momentum2.mean()?.to_scalar()?
916        );
917
918        Ok(())
919    }
920
921    #[test]
922    fn test_second_order_update() -> Result<()> {
923        let config = SOFOConfig::new().build();
924        let optimizer = SOFO::new(config);
925
926        let gradient = Tensor::ones(&[2, 2])?.mul_scalar(0.5)?;
927        let curvature = Tensor::ones(&[2, 2])?.mul_scalar(2.0)?;
928
929        let update = optimizer.compute_second_order_update(&gradient, &curvature)?;
930
931        // Update should be approximately gradient / curvature
932        let expected = 0.5 / 2.0; // Approximate expected value
933        let actual = update.mean()?.to_scalar()?;
934
935        assert!((actual - expected).abs() < 0.1);
936
937        Ok(())
938    }
939
940    #[test]
941    fn test_condition_number_control() -> Result<()> {
942        let config = SOFOConfig::new().damping(1e-3).max_condition_number(100.0).build();
943        let optimizer = SOFO::new(config);
944
945        // Create curvature with extreme values
946        let curvature = Tensor::from_vec(vec![1e-6_f32, 1e6, 1.0, 1e3], &[2, 2])?;
947
948        let controlled = optimizer.control_condition_number(&curvature)?;
949
950        // Values must be clamped into [damping, damping · max_condition_number].
951        let values = controlled.data_f32()?;
952        let max_val = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
953        let min_val = values.iter().copied().fold(f32::INFINITY, f32::min);
954
955        assert!(
956            max_val / min_val <= 100.0 * 1.1,
957            "condition number {} exceeds the configured bound",
958            max_val / min_val
959        );
960
961        Ok(())
962    }
963
964    #[test]
965    fn test_sofo_stats() -> Result<()> {
966        let config = SOFOConfig::new().forward_passes(4).build();
967        let mut optimizer = SOFO::new(config);
968
969        // Perform a few optimization steps
970        let mut parameters = HashMap::new();
971        parameters.insert("weight".to_string(), Tensor::ones(&[2, 2])?);
972
973        let mut gradients = HashMap::new();
974        gradients.insert(
975            "weight".to_string(),
976            Tensor::ones(&[2, 2])?.mul_scalar(0.1)?,
977        );
978
979        for _ in 0..3 {
980            optimizer.step(&mut parameters, &gradients)?;
981        }
982
983        let stats = optimizer.get_sofo_stats();
984        assert_eq!(stats.step, 3);
985        assert!(stats.num_parameters > 0);
986        assert!(
987            stats.state_bytes > 0,
988            "state size must be measured, not invented"
989        );
990        // `step` has no gradient oracle, so it cannot evaluate anything in forward
991        // mode: the counter must stay at zero rather than claim work never done.
992        assert_eq!(
993            stats.total_forward_passes, 0,
994            "the gradient-only path performs no forward-mode passes"
995        );
996        assert_eq!(
997            stats.curvature_source,
998            CurvatureSource::EmpiricalFisherDiagonal
999        );
1000
1001        Ok(())
1002    }
1003
1004    /// The oracle-driven path really does call the oracle, twice per probe.
1005    #[test]
1006    fn test_sofo_forward_passes_are_counted_only_when_performed() -> Result<()> {
1007        let config = SOFOConfig::new().forward_passes(2).build();
1008        let mut optimizer = SOFO::new(config);
1009
1010        let mut parameters = HashMap::new();
1011        parameters.insert("weight".to_string(), Tensor::ones(&[2, 2])?);
1012
1013        let mut calls = 0_usize;
1014        let mut oracle = |params: &HashMap<String, Tensor>| -> Result<HashMap<String, Tensor>> {
1015            calls += 1;
1016            let mut grads = HashMap::new();
1017            for (name, tensor) in params {
1018                grads.insert(name.clone(), tensor.mul_scalar(2.0)?);
1019            }
1020            Ok(grads)
1021        };
1022
1023        let mut gradients = HashMap::new();
1024        gradients.insert(
1025            "weight".to_string(),
1026            Tensor::ones(&[2, 2])?.mul_scalar(2.0)?,
1027        );
1028
1029        optimizer.step_with_gradient_oracle(&mut parameters, &gradients, &mut oracle)?;
1030
1031        let stats = optimizer.get_sofo_stats();
1032        assert!(calls > 0, "the oracle must actually be evaluated");
1033        assert_eq!(
1034            stats.total_forward_passes as usize, calls,
1035            "every counted forward pass must correspond to a real oracle call"
1036        );
1037        assert_eq!(
1038            stats.curvature_source,
1039            CurvatureSource::HutchinsonFromOracle
1040        );
1041
1042        Ok(())
1043    }
1044
1045    #[test]
1046    fn test_learning_rate_methods() {
1047        let config = SOFOConfig::new().learning_rate(1e-3).build();
1048        let mut optimizer = SOFO::new(config);
1049
1050        assert_eq!(optimizer.learning_rate(), 1e-3);
1051
1052        optimizer.set_learning_rate(2e-3);
1053        assert_eq!(optimizer.learning_rate(), 2e-3);
1054    }
1055
1056    #[test]
1057    fn test_weight_decay() -> Result<()> {
1058        let config = SOFOConfig::new()
1059            .learning_rate(1e-2)
1060            .weight_decay(1e-2)
1061            .forward_passes(2)
1062            .build();
1063        let mut optimizer = SOFO::new(config);
1064
1065        let mut parameters = HashMap::new();
1066        parameters.insert("weight".to_string(), Tensor::ones(&[2, 2])?);
1067
1068        let mut gradients = HashMap::new();
1069        gradients.insert("weight".to_string(), Tensor::zeros(&[2, 2])?);
1070
1071        let initial_param_value =
1072            parameters.get("weight").expect("Key not found").mean()?.to_scalar()?;
1073
1074        optimizer.step(&mut parameters, &gradients)?;
1075
1076        let final_param_value =
1077            parameters.get("weight").expect("Key not found").mean()?.to_scalar()?;
1078
1079        // With weight decay, parameter should decrease even with zero gradient
1080        assert!(final_param_value < initial_param_value);
1081
1082        Ok(())
1083    }
1084
1085    #[test]
1086    fn test_adaptive_curvature() -> Result<()> {
1087        let config = SOFOConfig::new().adaptive_curvature(true).curvature_strength(0.1).build();
1088        let mut optimizer = SOFO::new(config);
1089
1090        let gradient = Tensor::ones(&[2, 2])?.mul_scalar(0.5)?;
1091        let curvature = Tensor::ones(&[2, 2])?.mul_scalar(2.0)?;
1092
1093        let adaptive_curvature =
1094            optimizer.apply_adaptive_curvature("test", &curvature, &gradient)?;
1095
1096        // Adaptive curvature should be modified from original
1097        let original_mean = curvature.mean()?.to_scalar()?;
1098        let adaptive_mean = adaptive_curvature.mean()?.to_scalar()?;
1099
1100        assert_ne!(original_mean, adaptive_mean);
1101
1102        Ok(())
1103    }
1104}