Skip to main content

optirs_learned/lstm/
components.rs

1//! Real implementations of the LSTM optimizer's auxiliary components.
2//!
3//! This file replaces the block that used to sit at the bottom of `lstm.rs`
4//! under the comment *"Placeholder implementations for remaining complex
5//! components / These would be fully implemented in a production system"*
6//! (findings F6/F7). Concretely, what was there before:
7//!
8//! | component | old behaviour |
9//! |---|---|
10//! | `MetaLearner::step` | `Ok(T::zero())` — nothing trained |
11//! | `TransferLearner::transfer_to_domain` | all-zero `TransferResults` |
12//! | `AdaptiveLearningRateController::compute_lr` | returned `self.current_lr` unchanged, ignoring gradients, loss and history |
13//! | `OptimizationStateTracker::update` | empty body |
14//!
15//! Every one of them now computes something from its inputs. `MetaLearner::step`
16//! delegates to the truncated-BPTT meta-trainer in [`super::bptt`], which is
17//! where the actual learning happens.
18
19use scirs2_core::ndarray::{Array1, Array2};
20use scirs2_core::numeric::Float;
21use std::collections::{HashMap, VecDeque};
22use std::fmt::Debug;
23
24use super::bptt::{BpttConfig, DiagonalQuadraticTask};
25use super::trainer::MetaTrainer;
26use super::{
27    AdaptationEvent, AdaptiveLearningRateController, ConvergenceIndicators,
28    DomainSimilarityEstimator, FlowStability, GradientAnalyzer, GradientCorrelationTracker,
29    GradientFlowAnalyzer, GradientNoiseEstimator, GradientStatistics, HistoryBuffer,
30    InnerLoopState, LRAdaptationParams, LSTMNetwork, LossLandscapeAnalyzer, MetaLearner,
31    MetaLearningState, MetaTask, NoiseCharacteristics, NoiseType, OptimizationPhase,
32    OptimizationStateTracker, PerformanceTracker, PerformanceTrend, SimilarityFunction,
33    StabilityMetrics, TransferLearner, TransferMetrics, TransferResults,
34};
35use crate::error::{OptimError, Result};
36use crate::LearnedOptimizerConfig;
37
38/// How many recent losses the LR controller keeps for its trend estimate.
39const LR_TREND_WINDOW: usize = 8;
40
41/// How many samples the state tracker keeps in each trend vector.
42const STATE_TREND_WINDOW: usize = 32;
43
44/// Shortest truncated-BPTT horizon `MetaLearner::step` will use. Below ~4 steps
45/// the recurrent carry barely contributes and the meta-gradient degenerates
46/// towards plain backprop.
47const MIN_UNROLL_STEPS: usize = 4;
48
49/// Longest truncated-BPTT horizon `MetaLearner::step` will use. The unrolled tape
50/// costs `O(horizon · parameters)` memory, so this bounds one meta-step's cost.
51const MAX_UNROLL_STEPS: usize = 32;
52
53impl<T: Float + Debug + Send + Sync + 'static + Default + Clone> MetaLearner<T> {
54    pub(super) fn new(config: &LearnedOptimizerConfig) -> Result<Self> {
55        let meta_lr: T =
56            scirs2_core::numeric::NumCast::from(config.meta_learning_rate).unwrap_or_else(T::zero);
57        Ok(Self {
58            meta_parameters: HashMap::new(),
59            meta_gradients: HashMap::new(),
60            task_history: VecDeque::new(),
61            meta_state: MetaLearningState {
62                meta_step: 0,
63                meta_lr,
64                adaptation_rate: scirs2_core::numeric::NumCast::from(0.1)
65                    .unwrap_or_else(|| T::zero()),
66                meta_validation_performance: T::zero(),
67                adaptation_history: VecDeque::new(),
68                inner_loop_state: InnerLoopState {
69                    inner_step: 0,
70                    inner_parameters: Array1::zeros(1),
71                    inner_optimizer_state: HashMap::new(),
72                    inner_performance: T::zero(),
73                },
74            },
75            transfer_learner: TransferLearner {
76                source_knowledge: HashMap::new(),
77                adaptation_parameters: Array1::zeros(1),
78                transfer_metrics: TransferMetrics {
79                    efficiency: T::zero(),
80                    adaptation_speed: T::zero(),
81                    knowledge_retention: T::zero(),
82                    negative_transfer_score: T::zero(),
83                },
84                similarity_estimator: DomainSimilarityEstimator {
85                    domain_embeddings: HashMap::new(),
86                    similarity_params: Array1::zeros(1),
87                    similarity_function: SimilarityFunction::Cosine,
88                },
89            },
90        })
91    }
92
93    /// Run one meta-training step over `tasks`, updating `network`'s weights.
94    ///
95    /// Each `MetaTask`'s recorded trajectory is turned into a differentiable
96    /// diagonal-quadratic surrogate
97    /// ([`DiagonalQuadraticTask::from_trajectory`], which identifies the
98    /// curvature and optimum by per-coordinate least squares on the observed
99    /// `(parameters, gradient)` pairs). The LSTM controller is then unrolled on
100    /// those surrogates and its weights are updated by truncated
101    /// backpropagation through time — see [`MetaTrainer::meta_step`].
102    ///
103    /// Returns the mean meta-loss (the summed surrogate loss over the unrolled
104    /// horizon, averaged over tasks). Previously this returned `T::zero()` and
105    /// trained nothing.
106    ///
107    /// # Errors
108    /// Returns `Err` when `tasks` is empty, when no task has a trajectory long
109    /// enough to identify a surrogate, or when the surrogates disagree on
110    /// dimension.
111    pub(super) fn step(
112        &mut self,
113        tasks: &[MetaTask<T>],
114        network: &mut LSTMNetwork<T>,
115    ) -> Result<T> {
116        if tasks.is_empty() {
117            return Err(OptimError::InsufficientData(
118                "meta-learning step requires at least one task".to_string(),
119            ));
120        }
121
122        let mut surrogates: Vec<DiagonalQuadraticTask<T>> = Vec::with_capacity(tasks.len());
123        let mut weights: Vec<T> = Vec::with_capacity(tasks.len());
124        let mut rejected = 0usize;
125        for task in tasks {
126            match DiagonalQuadraticTask::from_trajectory(&task.training_trajectory) {
127                Ok(surrogate) => {
128                    surrogates.push(surrogate);
129                    weights.push(if task.weight > T::zero() {
130                        task.weight
131                    } else {
132                        T::one()
133                    });
134                }
135                Err(_) => rejected += 1,
136            }
137        }
138
139        if surrogates.is_empty() {
140            return Err(OptimError::InsufficientData(format!(
141                "none of the {} supplied tasks had a trajectory from which a \
142                 differentiable surrogate could be identified ({rejected} rejected); \
143                 each task needs at least two trajectory points with varying parameters",
144                tasks.len()
145            )));
146        }
147
148        // Unroll horizon: the shortest observed trajectory, clamped to a sane
149        // range. That is the horizon over which the surrogates were actually
150        // identified, so unrolling for about that long is what the data supports.
151        //
152        // This used to read `adaptation_history.len().clamp(8, 32).max(8)`, and
153        // `adaptation_history` had no writers anywhere — so the expression was
154        // always exactly `8`, dressed up as if it adapted to something.
155        let shortest_trajectory = tasks
156            .iter()
157            .map(|t| t.training_trajectory.len())
158            .min()
159            .unwrap_or(0);
160        let unroll = shortest_trajectory.clamp(MIN_UNROLL_STEPS, MAX_UNROLL_STEPS);
161        let bptt_config = BpttConfig {
162            unroll_steps: unroll,
163            meta_learning_rate: self.meta_state.meta_lr.to_f64().unwrap_or(1e-3),
164            gradient_clip: 1.0,
165        };
166        let mut trainer = MetaTrainer::new(bptt_config);
167        let meta_loss = trainer.meta_step(network, &surrogates, &weights)?;
168
169        // Record what was learned so the state is no longer write-only.
170        // `previous_loss` must be read *before* the field is overwritten,
171        // otherwise `performance_improvement` below is identically zero.
172        let previous_loss = self.meta_state.meta_validation_performance;
173        self.meta_state.meta_step += 1;
174        self.meta_state.meta_validation_performance = meta_loss;
175        for task in tasks.iter().take(16) {
176            self.task_history.push_back(task.clone());
177            if self.task_history.len() > 64 {
178                self.task_history.pop_front();
179            }
180        }
181        self.meta_parameters.insert(
182            "flattened_controller".to_string(),
183            Array1::from_vec(super::bptt::flatten_parameters(network)),
184        );
185        self.meta_gradients.insert(
186            "last_meta_gradient".to_string(),
187            Array1::from_vec(trainer.last_gradient_vector()),
188        );
189
190        // Record what this step actually achieved, so `adaptation_history` holds
191        // measurements instead of staying permanently empty.
192        self.meta_state
193            .adaptation_history
194            .push_back(AdaptationEvent {
195                source_task: tasks
196                    .first()
197                    .map(|t| t.id.clone())
198                    .unwrap_or_else(|| "unknown".to_string()),
199                target_task: tasks
200                    .last()
201                    .map(|t| t.id.clone())
202                    .unwrap_or_else(|| "unknown".to_string()),
203                adaptation_steps: unroll,
204                transfer_efficiency: if previous_loss > T::zero() {
205                    (previous_loss - meta_loss) / previous_loss
206                } else {
207                    T::zero()
208                },
209                performance_improvement: previous_loss - meta_loss,
210            });
211        while self.meta_state.adaptation_history.len() > 256 {
212            self.meta_state.adaptation_history.pop_front();
213        }
214
215        Ok(meta_loss)
216    }
217
218    /// Adaptation events recorded by `Self::step`, oldest first.
219    pub fn adaptation_history(&self) -> &VecDeque<AdaptationEvent<T>> {
220        &self.meta_state.adaptation_history
221    }
222
223    /// Number of meta-training steps performed so far.
224    pub fn meta_step_count(&self) -> usize {
225        self.meta_state.meta_step
226    }
227
228    /// Most recent meta-loss.
229    pub fn last_meta_loss(&self) -> T {
230        self.meta_state.meta_validation_performance
231    }
232}
233
234impl<T: Float + Debug + Send + Sync + 'static + Default + Clone> TransferLearner<T> {
235    /// Adapt an already-meta-trained controller to a new task domain.
236    ///
237    /// This is a real measurement, not the all-zero struct it used to return:
238    ///
239    /// 1. Evaluate the controller on the target domain's surrogates *before*
240    ///    adapting (`initial_performance` = mean unrolled loss).
241    /// 2. Run `adaptation_steps` BPTT meta-steps restricted to the target
242    ///    surrogates.
243    /// 3. Re-evaluate (`final_performance`).
244    /// 4. `transfer_efficiency` = relative loss reduction
245    ///    `(initial - final) / initial`, clamped to `[-1, 1]`; negative values
246    ///    are recorded as a `negative_transfer_score` rather than hidden.
247    ///
248    /// # Errors
249    /// Returns `Err` when no target task yields an identifiable surrogate.
250    pub(super) fn transfer_to_domain(
251        &mut self,
252        target_tasks: &[MetaTask<T>],
253        network: &mut LSTMNetwork<T>,
254    ) -> Result<TransferResults<T>> {
255        if target_tasks.is_empty() {
256            return Err(OptimError::InsufficientData(
257                "transfer requires at least one target task".to_string(),
258            ));
259        }
260
261        let mut surrogates = Vec::new();
262        let mut weights = Vec::new();
263        for task in target_tasks {
264            if let Ok(s) = DiagonalQuadraticTask::from_trajectory(&task.training_trajectory) {
265                surrogates.push(s);
266                weights.push(if task.weight > T::zero() {
267                    task.weight
268                } else {
269                    T::one()
270                });
271            }
272        }
273        if surrogates.is_empty() {
274            return Err(OptimError::InsufficientData(
275                "no target task had a trajectory from which a surrogate could be identified"
276                    .to_string(),
277            ));
278        }
279
280        let config = BpttConfig {
281            unroll_steps: 12,
282            meta_learning_rate: 1e-2,
283            gradient_clip: 1.0,
284        };
285        let mut trainer = MetaTrainer::new(config);
286
287        let initial_performance = trainer.evaluate(network, &surrogates, &weights)?;
288        let adaptation_steps = 8usize;
289        for _ in 0..adaptation_steps {
290            trainer.meta_step(network, &surrogates, &weights)?;
291        }
292        let final_performance = trainer.evaluate(network, &surrogates, &weights)?;
293
294        let efficiency = if initial_performance > T::zero() {
295            let ratio = (initial_performance - final_performance) / initial_performance;
296            let one = T::one();
297            if ratio > one {
298                one
299            } else if ratio < -one {
300                -one
301            } else {
302                ratio
303            }
304        } else {
305            T::zero()
306        };
307
308        self.transfer_metrics.efficiency = efficiency;
309        self.transfer_metrics.adaptation_speed = efficiency
310            / scirs2_core::numeric::NumCast::from(adaptation_steps.max(1)).unwrap_or_else(T::one);
311        self.transfer_metrics.knowledge_retention = if initial_performance > T::zero() {
312            let retained = final_performance / initial_performance;
313            if retained > T::one() {
314                T::zero()
315            } else {
316                T::one() - retained
317            }
318        } else {
319            T::zero()
320        };
321        self.transfer_metrics.negative_transfer_score = if efficiency < T::zero() {
322            -efficiency
323        } else {
324            T::zero()
325        };
326
327        Ok(TransferResults {
328            initial_performance,
329            final_performance,
330            adaptation_steps,
331            transfer_efficiency: efficiency,
332        })
333    }
334
335    /// Metrics recorded by the most recent `Self::transfer_to_domain` call.
336    pub fn metrics(&self) -> &TransferMetrics<T> {
337        &self.transfer_metrics
338    }
339}
340
341impl<T: Float + Debug + Send + Sync + 'static + Default + Clone> AdaptiveLearningRateController<T> {
342    pub(super) fn new(config: &LearnedOptimizerConfig) -> Result<Self> {
343        let base_lr: T =
344            scirs2_core::numeric::NumCast::from(config.learning_rate).unwrap_or_else(T::zero);
345        if base_lr <= T::zero() {
346            return Err(OptimError::InvalidConfig(
347                "learning_rate must be positive".to_string(),
348            ));
349        }
350        let momentum: T = scirs2_core::numeric::NumCast::from(if config.use_momentum {
351            config.momentum_decay
352        } else {
353            0.0
354        })
355        .unwrap_or_else(T::zero);
356
357        Ok(Self {
358            base_lr,
359            current_lr: base_lr,
360            adaptation_params: LRAdaptationParams {
361                momentum,
362                gradient_sensitivity: scirs2_core::numeric::NumCast::from(0.1)
363                    .unwrap_or_else(|| T::zero()),
364                loss_sensitivity: scirs2_core::numeric::NumCast::from(0.1)
365                    .unwrap_or_else(|| T::zero()),
366                // Bound the schedule to two orders of magnitude either side of
367                // the configured base rate instead of fixed 1e-6 / 0.1 constants.
368                min_lr: base_lr / scirs2_core::numeric::NumCast::from(100.0).unwrap_or_else(T::one),
369                max_lr: base_lr * scirs2_core::numeric::NumCast::from(100.0).unwrap_or_else(T::one),
370                adaptation_rate: scirs2_core::numeric::NumCast::from(0.01)
371                    .unwrap_or_else(|| T::zero()),
372            },
373            lr_history: VecDeque::new(),
374            performance_tracker: PerformanceTracker {
375                recent_losses: VecDeque::new(),
376                trend: PerformanceTrend::Unknown,
377                stagnation_counter: 0,
378                best_performance: T::infinity(),
379                improvement_rate: T::zero(),
380            },
381        })
382    }
383
384    /// Compute the learning rate for the next step from live signals.
385    ///
386    /// Three multiplicative factors, all derived from the arguments:
387    ///
388    /// * **Gradient-norm normalization** — `1 / (1 + s·‖g‖)` with
389    ///   `s = gradient_sensitivity`. A large gradient shrinks the step, which is
390    ///   the standard defence against the divergence a fixed rate invites.
391    /// * **Loss trend** — the sign of the recent loss slope. A falling loss
392    ///   grows the rate by `1 + adaptation_rate`, a rising one shrinks it by
393    ///   `1 - adaptation_rate`.
394    /// * **Stagnation escape** — after `LR_TREND_WINDOW` consecutive steps with
395    ///   no improvement on the best loss seen, the rate is boosted by
396    ///   `1 + loss_sensitivity` to escape a plateau.
397    ///
398    /// The result is smoothed with `momentum` against the previous rate and
399    /// clamped to `[min_lr, max_lr]`. Previously this returned `self.current_lr`
400    /// unchanged and read none of its arguments.
401    ///
402    /// # Errors
403    /// Returns `Err` when `gradients` is empty — there is no signal to adapt to.
404    pub(super) fn compute_lr(
405        &mut self,
406        gradients: &Array1<T>,
407        loss: Option<T>,
408        history: &HistoryBuffer<T>,
409    ) -> Result<T> {
410        if gradients.is_empty() {
411            return Err(OptimError::InsufficientData(
412                "cannot compute an adaptive learning rate from an empty gradient".to_string(),
413            ));
414        }
415
416        let grad_norm = gradients
417            .iter()
418            .map(|&g| g * g)
419            .fold(T::zero(), |a, b| a + b)
420            .sqrt();
421
422        // --- gradient factor ------------------------------------------------
423        let grad_factor =
424            T::one() / (T::one() + self.adaptation_params.gradient_sensitivity * grad_norm);
425
426        // --- loss trend factor ----------------------------------------------
427        if let Some(l) = loss {
428            self.performance_tracker.recent_losses.push_back(l);
429            while self.performance_tracker.recent_losses.len() > LR_TREND_WINDOW {
430                self.performance_tracker.recent_losses.pop_front();
431            }
432            if l < self.performance_tracker.best_performance {
433                self.performance_tracker.best_performance = l;
434                self.performance_tracker.stagnation_counter = 0;
435            } else {
436                self.performance_tracker.stagnation_counter += 1;
437            }
438        }
439
440        // Prefer the controller's own window; fall back to the shared history
441        // buffer when the caller passes no per-step loss.
442        let losses: Vec<T> = if self.performance_tracker.recent_losses.len() >= 2 {
443            self.performance_tracker
444                .recent_losses
445                .iter()
446                .copied()
447                .collect()
448        } else {
449            history
450                .losses
451                .iter()
452                .rev()
453                .take(LR_TREND_WINDOW)
454                .rev()
455                .copied()
456                .collect()
457        };
458
459        let (trend_factor, improvement_rate) = if losses.len() >= 2 {
460            let first = losses[0];
461            let last = losses[losses.len() - 1];
462            let rate = if first.abs() > T::zero() {
463                (first - last) / first.abs()
464            } else {
465                T::zero()
466            };
467            let factor = if last < first {
468                T::one() + self.adaptation_params.adaptation_rate
469            } else if last > first {
470                T::one() - self.adaptation_params.adaptation_rate
471            } else {
472                T::one()
473            };
474            (factor, rate)
475        } else {
476            (T::one(), T::zero())
477        };
478        self.performance_tracker.improvement_rate = improvement_rate;
479        self.performance_tracker.trend = if losses.len() < 2 {
480            PerformanceTrend::Unknown
481        } else if improvement_rate > T::zero() {
482            PerformanceTrend::Improving
483        } else if improvement_rate < T::zero() {
484            PerformanceTrend::Degrading
485        } else {
486            PerformanceTrend::Stagnating
487        };
488
489        // --- stagnation escape ----------------------------------------------
490        let stagnation_factor = if self.performance_tracker.stagnation_counter >= LR_TREND_WINDOW {
491            T::one() + self.adaptation_params.loss_sensitivity
492        } else {
493            T::one()
494        };
495
496        let target = self.base_lr * grad_factor * trend_factor * stagnation_factor;
497
498        // --- momentum smoothing + clamping ----------------------------------
499        let m = self.adaptation_params.momentum;
500        let smoothed = m * self.current_lr + (T::one() - m) * target;
501        let clamped = if smoothed < self.adaptation_params.min_lr {
502            self.adaptation_params.min_lr
503        } else if smoothed > self.adaptation_params.max_lr {
504            self.adaptation_params.max_lr
505        } else {
506            smoothed
507        };
508
509        self.current_lr = clamped;
510        self.lr_history.push_back(clamped);
511        while self.lr_history.len() > 1000 {
512            self.lr_history.pop_front();
513        }
514        Ok(clamped)
515    }
516
517    /// Current learning rate.
518    pub fn current_lr(&self) -> T {
519        self.current_lr
520    }
521
522    /// Observed performance trend.
523    pub fn trend(&self) -> PerformanceTrend {
524        self.performance_tracker.trend
525    }
526
527    /// Consecutive steps without a new best loss.
528    pub fn stagnation_counter(&self) -> usize {
529        self.performance_tracker.stagnation_counter
530    }
531}
532
533impl<T: Float + Debug + Send + Sync + 'static + Default + Clone> OptimizationStateTracker<T> {
534    pub(super) fn new() -> Self {
535        Self {
536            phase: OptimizationPhase::InitialDescent,
537            convergence_indicators: ConvergenceIndicators {
538                gradient_norm_trend: Vec::new(),
539                loss_change_trend: Vec::new(),
540                parameter_change_magnitude: T::zero(),
541                convergence_probability: T::zero(),
542                estimated_steps_to_convergence: None,
543            },
544            gradient_analyzer: GradientAnalyzer {
545                gradient_stats: GradientStatistics {
546                    mean_norm: T::zero(),
547                    norm_variance: T::zero(),
548                    direction_consistency: T::zero(),
549                    magnitude_distribution: Vec::new(),
550                    component_stats: Array1::zeros(1),
551                },
552                correlation_tracker: GradientCorrelationTracker {
553                    correlation_matrix: Array2::zeros((1, 1)),
554                    temporal_correlations: VecDeque::new(),
555                    cross_correlations: HashMap::new(),
556                },
557                noise_estimator: GradientNoiseEstimator {
558                    noise_level: T::zero(),
559                    signal_to_noise_ratio: T::zero(),
560                    noise_characteristics: NoiseCharacteristics {
561                        noise_type: NoiseType::White,
562                        scale: T::zero(),
563                        temporal_correlation: T::zero(),
564                        spatial_correlation: T::zero(),
565                    },
566                },
567                flow_analyzer: GradientFlowAnalyzer {
568                    flow_field: Array2::zeros((1, 1)),
569                    critical_points: Vec::new(),
570                    stability: FlowStability::Unknown,
571                    attractors: Vec::new(),
572                    repellers: Vec::new(),
573                },
574            },
575            landscape_analyzer: LossLandscapeAnalyzer {
576                local_curvature: T::zero(),
577                hessian_eigenvalues: None,
578                roughness: T::zero(),
579                basin_size: T::zero(),
580                barrier_heights: Vec::new(),
581            },
582            stability_metrics: StabilityMetrics {
583                lyapunov_exponents: Array1::zeros(1),
584                stability_margin: T::zero(),
585                perturbation_sensitivity: T::zero(),
586                robustness_score: T::zero(),
587            },
588            previous_gradient: None,
589            previous_loss: None,
590            step_count: 0,
591        }
592    }
593
594    /// Update every tracked statistic from the observed step.
595    ///
596    /// The body used to be empty (`// Placeholder state update`), so
597    /// `LSTMOptimizer::get_state_analysis` reported the all-zero struct built by
598    /// [`Self::new`] forever. Now, per step:
599    ///
600    /// * `gradient_norm_trend` / `loss_change_trend` get one sample each
601    ///   (bounded to `STATE_TREND_WINDOW`).
602    /// * `mean_norm` and `norm_variance` are Welford running moments of `‖g‖`.
603    /// * `direction_consistency` is the cosine similarity with the previous
604    ///   gradient.
605    /// * `noise_level` is the norm of the gradient *difference* (a
606    ///   finite-difference noise proxy) and `signal_to_noise_ratio` is
607    ///   `‖g‖ / noise_level`.
608    /// * `local_curvature` is the secant estimate
609    ///   `‖g_t − g_{t−1}‖ / ‖Δθ‖` — the directional curvature along the step
610    ///   actually taken.
611    /// * `parameter_change_magnitude` is `‖Δθ‖`.
612    /// * `convergence_probability` is `1 / (1 + ‖g‖)`, and `phase` is derived
613    ///   from the gradient-norm trend.
614    pub(super) fn update(&mut self, gradients: &Array1<T>, updates: &Array1<T>, loss: Option<T>) {
615        if gradients.is_empty() {
616            return;
617        }
618        self.step_count += 1;
619        let n_t: T = scirs2_core::numeric::NumCast::from(self.step_count).unwrap_or_else(T::one);
620
621        let grad_norm = gradients
622            .iter()
623            .map(|&g| g * g)
624            .fold(T::zero(), |a, b| a + b)
625            .sqrt();
626        let update_norm = updates
627            .iter()
628            .map(|&u| u * u)
629            .fold(T::zero(), |a, b| a + b)
630            .sqrt();
631
632        // --- trends ----------------------------------------------------------
633        self.convergence_indicators
634            .gradient_norm_trend
635            .push(grad_norm);
636        if self.convergence_indicators.gradient_norm_trend.len() > STATE_TREND_WINDOW {
637            self.convergence_indicators.gradient_norm_trend.remove(0);
638        }
639        if let (Some(current), Some(previous)) = (loss, self.previous_loss) {
640            self.convergence_indicators
641                .loss_change_trend
642                .push(current - previous);
643            if self.convergence_indicators.loss_change_trend.len() > STATE_TREND_WINDOW {
644                self.convergence_indicators.loss_change_trend.remove(0);
645            }
646        }
647        self.convergence_indicators.parameter_change_magnitude = update_norm;
648        self.convergence_indicators.convergence_probability = T::one() / (T::one() + grad_norm);
649
650        // --- Welford moments of the gradient norm ---------------------------
651        let delta = grad_norm - self.gradient_analyzer.gradient_stats.mean_norm;
652        self.gradient_analyzer.gradient_stats.mean_norm =
653            self.gradient_analyzer.gradient_stats.mean_norm + delta / n_t;
654        let delta2 = grad_norm - self.gradient_analyzer.gradient_stats.mean_norm;
655        // Running population variance: M2/n, accumulated in `norm_variance`.
656        let prev_m2 = self.gradient_analyzer.gradient_stats.norm_variance * (n_t - T::one());
657        let m2 = prev_m2 + delta * delta2;
658        self.gradient_analyzer.gradient_stats.norm_variance = m2 / n_t;
659
660        self.gradient_analyzer
661            .gradient_stats
662            .magnitude_distribution
663            .push(grad_norm);
664        if self
665            .gradient_analyzer
666            .gradient_stats
667            .magnitude_distribution
668            .len()
669            > STATE_TREND_WINDOW
670        {
671            self.gradient_analyzer
672                .gradient_stats
673                .magnitude_distribution
674                .remove(0);
675        }
676        self.gradient_analyzer.gradient_stats.component_stats = gradients.mapv(|g| g.abs());
677
678        // --- direction consistency, noise, curvature ------------------------
679        if let Some(previous) = &self.previous_gradient {
680            let width = previous.len().min(gradients.len());
681            if width > 0 {
682                let mut dot = T::zero();
683                let mut prev_sq = T::zero();
684                let mut diff_sq = T::zero();
685                for i in 0..width {
686                    let p = previous[i];
687                    let g = gradients[i];
688                    dot = dot + p * g;
689                    prev_sq = prev_sq + p * p;
690                    diff_sq = diff_sq + (g - p) * (g - p);
691                }
692                let prev_norm = prev_sq.sqrt();
693                if prev_norm > T::zero() && grad_norm > T::zero() {
694                    self.gradient_analyzer.gradient_stats.direction_consistency =
695                        dot / (prev_norm * grad_norm);
696                }
697                let noise = diff_sq.sqrt();
698                self.gradient_analyzer.noise_estimator.noise_level = noise;
699                self.gradient_analyzer.noise_estimator.signal_to_noise_ratio = if noise > T::zero()
700                {
701                    grad_norm / noise
702                } else {
703                    T::infinity()
704                };
705                self.gradient_analyzer
706                    .noise_estimator
707                    .noise_characteristics
708                    .scale = noise;
709                self.gradient_analyzer
710                    .noise_estimator
711                    .noise_characteristics
712                    .temporal_correlation =
713                    self.gradient_analyzer.gradient_stats.direction_consistency;
714
715                // Secant curvature along the step actually taken.
716                if update_norm > T::zero() {
717                    self.landscape_analyzer.local_curvature = noise / update_norm;
718                }
719            }
720        }
721
722        // --- roughness & stability -------------------------------------------
723        let trend = &self.convergence_indicators.gradient_norm_trend;
724        if trend.len() >= 3 {
725            let mut second_diff = T::zero();
726            for w in trend.windows(3) {
727                second_diff = second_diff + (w[2] - w[1] - (w[1] - w[0])).abs();
728            }
729            let count: T =
730                scirs2_core::numeric::NumCast::from(trend.len() - 2).unwrap_or_else(T::one);
731            let mean_second = second_diff / count;
732            self.landscape_analyzer.roughness = mean_second / (T::one() + mean_second);
733        }
734        self.stability_metrics.perturbation_sensitivity =
735            self.gradient_analyzer.noise_estimator.noise_level;
736        self.stability_metrics.stability_margin =
737            T::one() / (T::one() + self.landscape_analyzer.local_curvature);
738        self.stability_metrics.robustness_score = (T::one()
739            + self.gradient_analyzer.gradient_stats.direction_consistency)
740            / scirs2_core::numeric::NumCast::from(2.0).unwrap_or_else(T::one);
741
742        // --- phase ------------------------------------------------------------
743        self.phase = Self::classify_phase(
744            trend,
745            self.gradient_analyzer.gradient_stats.direction_consistency,
746        );
747
748        self.previous_gradient = Some(gradients.clone());
749        if let Some(l) = loss {
750            self.previous_loss = Some(l);
751        }
752    }
753
754    /// Classify the optimization phase from the gradient-norm trend.
755    ///
756    /// * fewer than 4 samples → `InitialDescent`
757    /// * norm shrinking fast (last < 25% of first) → `Converged`
758    /// * norm shrinking moderately (< 60%) → `FineTuning`
759    /// * norm growing (> 150%) → `Diverging`
760    /// * norm roughly flat (> 90%) → `Plateau`
761    /// * otherwise → `SteadyProgress`
762    fn classify_phase(trend: &[T], consistency: T) -> OptimizationPhase {
763        if trend.len() < 4 {
764            return OptimizationPhase::InitialDescent;
765        }
766        let first = trend[0];
767        let last = trend[trend.len() - 1];
768        if first <= T::zero() {
769            return OptimizationPhase::Converged;
770        }
771        let ratio = (last / first).to_f64().unwrap_or(1.0);
772        let _ = consistency;
773        if ratio < 0.25 {
774            OptimizationPhase::Converged
775        } else if ratio > 1.5 {
776            OptimizationPhase::Diverging
777        } else if ratio > 0.9 {
778            OptimizationPhase::Plateau
779        } else if ratio < 0.6 {
780            OptimizationPhase::FineTuning
781        } else {
782            OptimizationPhase::SteadyProgress
783        }
784    }
785
786    /// Number of steps observed.
787    pub fn observed_steps(&self) -> usize {
788        self.step_count
789    }
790}