Skip to main content

ipfrs_tensorlogic/
optimization_history.rs

1//! TensorOptimizationHistory — records and analyzes optimization steps
2//! (loss/gradient values) to detect convergence, track best results, and
3//! guide adaptive learning rate schedules.
4
5// ---------------------------------------------------------------------------
6// OptimizationStep
7// ---------------------------------------------------------------------------
8
9/// A single recorded step in the optimization process.
10#[derive(Clone, Debug, PartialEq)]
11pub struct OptimizationStep {
12    /// Monotonically increasing step index.
13    pub step: u64,
14    /// Loss value at this step.
15    pub loss: f64,
16    /// L2 norm of the gradient at this step.
17    pub gradient_norm: f64,
18    /// Learning rate used at this step.
19    pub learning_rate: f64,
20    /// Logical clock tick when this step was recorded.
21    pub timestamp_tick: u64,
22}
23
24// ---------------------------------------------------------------------------
25// ConvergenceStatus
26// ---------------------------------------------------------------------------
27
28/// The convergence state inferred from recent optimization history.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum ConvergenceStatus {
31    /// Optimization has not yet converged.
32    NotConverged,
33    /// Recent improvement is below the threshold but not sustained long enough
34    /// to be certain.
35    PossiblyConverged,
36    /// Sustained low improvement for `patience` consecutive steps — the
37    /// optimizer is considered converged.
38    Converged,
39}
40
41// ---------------------------------------------------------------------------
42// OptimizationHistoryConfig
43// ---------------------------------------------------------------------------
44
45/// Configuration for [`TensorOptimizationHistory`].
46#[derive(Clone, Debug, PartialEq)]
47pub struct OptimizationHistoryConfig {
48    /// Maximum number of history entries.  When the limit is reached the
49    /// oldest entry is evicted.
50    pub max_steps: usize,
51    /// Number of consecutive steps with improvement < `convergence_threshold`
52    /// required before declaring [`ConvergenceStatus::Converged`].
53    pub convergence_patience: usize,
54    /// Minimum loss improvement required to count a step as making progress.
55    pub convergence_threshold: f64,
56}
57
58impl Default for OptimizationHistoryConfig {
59    fn default() -> Self {
60        Self {
61            max_steps: 1000,
62            convergence_patience: 10,
63            convergence_threshold: 1e-6,
64        }
65    }
66}
67
68// ---------------------------------------------------------------------------
69// HistoryStats
70// ---------------------------------------------------------------------------
71
72/// Aggregated statistics computed over the entire recorded history.
73#[derive(Clone, Debug, PartialEq)]
74pub struct HistoryStats {
75    /// Total number of steps currently retained in the history buffer.
76    pub total_steps: usize,
77    /// The lowest loss value ever seen (across all recorded steps).
78    pub best_loss: f64,
79    /// The step index at which `best_loss` was achieved.
80    pub best_step: u64,
81    /// Loss at the most recently recorded step.
82    pub current_loss: f64,
83    /// Arithmetic mean of all recorded `gradient_norm` values.
84    pub avg_gradient_norm: f64,
85    /// Current convergence status.
86    pub convergence_status: ConvergenceStatus,
87}
88
89// ---------------------------------------------------------------------------
90// TensorOptimizationHistory
91// ---------------------------------------------------------------------------
92
93/// Records and analyzes a history of optimization steps.
94///
95/// # Convergence detection
96///
97/// After each [`record`](TensorOptimizationHistory::record) call the tracker
98/// compares the new loss against the previous best.  If the improvement
99/// (previous_best − new_loss) is below
100/// [`OptimizationHistoryConfig::convergence_threshold`] the
101/// `consecutive_no_progress` counter is incremented; otherwise it is reset to
102/// zero.  Once the counter reaches
103/// [`OptimizationHistoryConfig::convergence_patience`] the status transitions
104/// to [`ConvergenceStatus::Converged`]; once it reaches `patience / 2`
105/// (integer division) it transitions to
106/// [`ConvergenceStatus::PossiblyConverged`].
107pub struct TensorOptimizationHistory {
108    /// Retained optimization steps (oldest first).
109    pub steps: Vec<OptimizationStep>,
110    /// Configuration.
111    pub config: OptimizationHistoryConfig,
112    /// Best (lowest) loss seen so far.
113    pub best_loss: f64,
114    /// Step index that achieved `best_loss`.
115    pub best_step: u64,
116    /// Number of consecutive steps that did not improve loss by at least
117    /// `convergence_threshold`.
118    pub consecutive_no_progress: usize,
119}
120
121impl TensorOptimizationHistory {
122    /// Creates a new history tracker with the given configuration.
123    pub fn new(config: OptimizationHistoryConfig) -> Self {
124        Self {
125            steps: Vec::new(),
126            config,
127            best_loss: f64::MAX,
128            best_step: 0,
129            consecutive_no_progress: 0,
130        }
131    }
132
133    /// Records a new optimization step.
134    ///
135    /// If the history buffer is full the oldest entry is evicted.  Best loss
136    /// tracking and consecutive-no-progress counting are updated accordingly.
137    pub fn record(&mut self, step: OptimizationStep) {
138        // Evict oldest entry if at capacity.
139        if self.steps.len() >= self.config.max_steps {
140            self.steps.remove(0);
141        }
142
143        let new_loss = step.loss;
144        let prev_best = self.best_loss;
145
146        // First record initialises best tracking.
147        if prev_best == f64::MAX {
148            self.best_loss = new_loss;
149            self.best_step = step.step;
150            self.consecutive_no_progress = 0;
151            self.steps.push(step);
152            return;
153        }
154
155        // Compute improvement relative to best seen so far.
156        let improvement = prev_best - new_loss;
157
158        if new_loss < self.best_loss {
159            self.best_loss = new_loss;
160            self.best_step = step.step;
161        }
162
163        if improvement < self.config.convergence_threshold {
164            self.consecutive_no_progress += 1;
165        } else {
166            self.consecutive_no_progress = 0;
167        }
168
169        self.steps.push(step);
170    }
171
172    /// Returns the current [`ConvergenceStatus`] based on the consecutive
173    /// no-progress counter.
174    pub fn convergence_status(&self) -> ConvergenceStatus {
175        if self.steps.is_empty() {
176            return ConvergenceStatus::NotConverged;
177        }
178        let patience = self.config.convergence_patience;
179        if self.consecutive_no_progress >= patience {
180            ConvergenceStatus::Converged
181        } else if self.consecutive_no_progress >= patience / 2 {
182            ConvergenceStatus::PossiblyConverged
183        } else {
184            ConvergenceStatus::NotConverged
185        }
186    }
187
188    /// Computes the total loss improvement over the last `n` steps.
189    ///
190    /// Returns `first_loss_in_window − last_loss_in_window`; a positive value
191    /// means the loss is decreasing (improving).  Returns `0.0` when fewer
192    /// than two steps are available.
193    pub fn recent_improvement(&self, n: usize) -> f64 {
194        if self.steps.len() < 2 {
195            return 0.0;
196        }
197        let window_n = n.min(self.steps.len());
198        let window_start = self.steps.len() - window_n;
199        let first_loss = self.steps[window_start].loss;
200        let last_loss = self.steps[self.steps.len() - 1].loss;
201        first_loss - last_loss
202    }
203
204    /// Returns the arithmetic mean of all recorded `gradient_norm` values.
205    ///
206    /// Returns `0.0` when no steps have been recorded.
207    pub fn avg_gradient_norm(&self) -> f64 {
208        if self.steps.is_empty() {
209            return 0.0;
210        }
211        let sum: f64 = self.steps.iter().map(|s| s.gradient_norm).sum();
212        sum / self.steps.len() as f64
213    }
214
215    /// Computes and returns aggregated [`HistoryStats`] for the current
216    /// history buffer.
217    pub fn stats(&self) -> HistoryStats {
218        let current_loss = self.steps.last().map(|s| s.loss).unwrap_or(f64::MAX);
219
220        HistoryStats {
221            total_steps: self.steps.len(),
222            best_loss: self.best_loss,
223            best_step: self.best_step,
224            current_loss,
225            avg_gradient_norm: self.avg_gradient_norm(),
226            convergence_status: self.convergence_status(),
227        }
228    }
229
230    /// Returns a reference to the most recently recorded step, or `None` if
231    /// the history is empty.
232    pub fn last_step(&self) -> Option<&OptimizationStep> {
233        self.steps.last()
234    }
235
236    /// Resets all history and tracking state.
237    pub fn reset(&mut self) {
238        self.steps.clear();
239        self.best_loss = f64::MAX;
240        self.best_step = 0;
241        self.consecutive_no_progress = 0;
242    }
243}
244
245// ---------------------------------------------------------------------------
246// Tests
247// ---------------------------------------------------------------------------
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    fn default_history() -> TensorOptimizationHistory {
254        TensorOptimizationHistory::new(OptimizationHistoryConfig::default())
255    }
256
257    fn make_step(step: u64, loss: f64) -> OptimizationStep {
258        OptimizationStep {
259            step,
260            loss,
261            gradient_norm: 0.5,
262            learning_rate: 0.01,
263            timestamp_tick: step,
264        }
265    }
266
267    fn make_step_full(step: u64, loss: f64, gradient_norm: f64) -> OptimizationStep {
268        OptimizationStep {
269            step,
270            loss,
271            gradient_norm,
272            learning_rate: 0.01,
273            timestamp_tick: step,
274        }
275    }
276
277    // -----------------------------------------------------------------------
278    // 1. record adds step to history
279    // -----------------------------------------------------------------------
280
281    #[test]
282    fn test_record_adds_step() {
283        let mut h = default_history();
284        h.record(make_step(0, 1.0));
285        assert_eq!(h.steps.len(), 1);
286    }
287
288    #[test]
289    fn test_record_multiple_steps() {
290        let mut h = default_history();
291        for i in 0..5u64 {
292            h.record(make_step(i, 1.0 - i as f64 * 0.1));
293        }
294        assert_eq!(h.steps.len(), 5);
295    }
296
297    // -----------------------------------------------------------------------
298    // 2. max_steps eviction removes oldest
299    // -----------------------------------------------------------------------
300
301    #[test]
302    fn test_max_steps_eviction() {
303        let config = OptimizationHistoryConfig {
304            max_steps: 3,
305            convergence_patience: 10,
306            convergence_threshold: 1e-6,
307        };
308        let mut h = TensorOptimizationHistory::new(config);
309        for i in 0..5u64 {
310            h.record(make_step(i, 1.0 - i as f64 * 0.01));
311        }
312        assert_eq!(h.steps.len(), 3);
313        // Oldest (step 0, 1) should have been evicted; first remaining is step 2.
314        assert_eq!(h.steps[0].step, 2);
315    }
316
317    #[test]
318    fn test_max_steps_boundary() {
319        let config = OptimizationHistoryConfig {
320            max_steps: 1,
321            convergence_patience: 5,
322            convergence_threshold: 1e-6,
323        };
324        let mut h = TensorOptimizationHistory::new(config);
325        h.record(make_step(0, 2.0));
326        h.record(make_step(1, 1.0));
327        assert_eq!(h.steps.len(), 1);
328        assert_eq!(h.steps[0].step, 1);
329    }
330
331    // -----------------------------------------------------------------------
332    // 3. best_loss and best_step track minimum
333    // -----------------------------------------------------------------------
334
335    #[test]
336    fn test_best_loss_tracks_minimum() {
337        let mut h = default_history();
338        h.record(make_step(0, 3.0));
339        h.record(make_step(1, 1.0));
340        h.record(make_step(2, 2.0));
341        assert!((h.best_loss - 1.0).abs() < 1e-12);
342        assert_eq!(h.best_step, 1);
343    }
344
345    #[test]
346    fn test_best_loss_initialized_correctly() {
347        let mut h = default_history();
348        h.record(make_step(5, 42.0));
349        assert!((h.best_loss - 42.0).abs() < 1e-12);
350        assert_eq!(h.best_step, 5);
351    }
352
353    #[test]
354    fn test_best_step_updates_when_loss_decreases() {
355        let mut h = default_history();
356        h.record(make_step(0, 10.0));
357        h.record(make_step(1, 5.0));
358        h.record(make_step(2, 7.0));
359        h.record(make_step(3, 2.0));
360        assert!((h.best_loss - 2.0).abs() < 1e-12);
361        assert_eq!(h.best_step, 3);
362    }
363
364    // -----------------------------------------------------------------------
365    // 4. convergence_status: NotConverged / PossiblyConverged / Converged
366    // -----------------------------------------------------------------------
367
368    #[test]
369    fn test_convergence_status_empty() {
370        let h = default_history();
371        assert_eq!(h.convergence_status(), ConvergenceStatus::NotConverged);
372    }
373
374    #[test]
375    fn test_convergence_status_not_converged() {
376        let mut h = default_history();
377        // Big decreasing improvements each step.
378        for i in 0..5u64 {
379            h.record(make_step(i, 100.0 - i as f64 * 10.0));
380        }
381        assert_eq!(h.convergence_status(), ConvergenceStatus::NotConverged);
382    }
383
384    #[test]
385    fn test_convergence_status_converged() {
386        let config = OptimizationHistoryConfig {
387            max_steps: 1000,
388            convergence_patience: 5,
389            convergence_threshold: 1e-3,
390        };
391        let mut h = TensorOptimizationHistory::new(config);
392        // First step sets best.
393        h.record(make_step(0, 1.0));
394        // Record 5 steps with negligible improvement (below threshold).
395        for i in 1..=5u64 {
396            h.record(make_step(i, 1.0 - i as f64 * 1e-9));
397        }
398        assert_eq!(h.convergence_status(), ConvergenceStatus::Converged);
399    }
400
401    #[test]
402    fn test_convergence_status_possibly_converged() {
403        let config = OptimizationHistoryConfig {
404            max_steps: 1000,
405            convergence_patience: 8,
406            convergence_threshold: 1e-3,
407        };
408        let mut h = TensorOptimizationHistory::new(config);
409        // First step sets best.
410        h.record(make_step(0, 1.0));
411        // Record patience/2 = 4 steps with negligible improvement.
412        for i in 1..=4u64 {
413            h.record(make_step(i, 1.0 - i as f64 * 1e-9));
414        }
415        assert_eq!(h.convergence_status(), ConvergenceStatus::PossiblyConverged);
416    }
417
418    // -----------------------------------------------------------------------
419    // 5. patience/2 boundary for PossiblyConverged
420    // -----------------------------------------------------------------------
421
422    #[test]
423    fn test_possibly_converged_boundary_exact() {
424        let config = OptimizationHistoryConfig {
425            max_steps: 1000,
426            convergence_patience: 10,
427            convergence_threshold: 1e-3,
428        };
429        let mut h = TensorOptimizationHistory::new(config);
430        h.record(make_step(0, 1.0));
431        // patience/2 = 5 steps without progress → PossiblyConverged.
432        for i in 1..=5u64 {
433            h.record(make_step(i, 1.0 - i as f64 * 1e-9));
434        }
435        assert_eq!(h.convergence_status(), ConvergenceStatus::PossiblyConverged);
436    }
437
438    #[test]
439    fn test_not_converged_below_patience_half() {
440        let config = OptimizationHistoryConfig {
441            max_steps: 1000,
442            convergence_patience: 10,
443            convergence_threshold: 1e-3,
444        };
445        let mut h = TensorOptimizationHistory::new(config);
446        h.record(make_step(0, 1.0));
447        // Only 4 steps without progress → still NotConverged (4 < patience/2=5).
448        for i in 1..=4u64 {
449            h.record(make_step(i, 1.0 - i as f64 * 1e-9));
450        }
451        assert_eq!(h.convergence_status(), ConvergenceStatus::NotConverged);
452    }
453
454    #[test]
455    fn test_converged_at_full_patience() {
456        let config = OptimizationHistoryConfig {
457            max_steps: 1000,
458            convergence_patience: 6,
459            convergence_threshold: 1e-3,
460        };
461        let mut h = TensorOptimizationHistory::new(config);
462        h.record(make_step(0, 1.0));
463        for i in 1..=6u64 {
464            h.record(make_step(i, 1.0 - i as f64 * 1e-9));
465        }
466        assert_eq!(h.convergence_status(), ConvergenceStatus::Converged);
467    }
468
469    // -----------------------------------------------------------------------
470    // 6. consecutive_no_progress resets on real improvement
471    // -----------------------------------------------------------------------
472
473    #[test]
474    fn test_no_progress_counter_resets() {
475        let config = OptimizationHistoryConfig {
476            max_steps: 1000,
477            convergence_patience: 3,
478            convergence_threshold: 1e-3,
479        };
480        let mut h = TensorOptimizationHistory::new(config);
481        h.record(make_step(0, 1.0));
482        // 3 tiny steps → would be Converged if not reset.
483        h.record(make_step(1, 1.0 - 1e-9));
484        h.record(make_step(2, 1.0 - 2e-9));
485        // A big improvement resets the counter.
486        h.record(make_step(3, 0.0));
487        // Now only 0 no-progress steps → NotConverged.
488        assert_eq!(h.convergence_status(), ConvergenceStatus::NotConverged);
489    }
490
491    // -----------------------------------------------------------------------
492    // 7. recent_improvement
493    // -----------------------------------------------------------------------
494
495    #[test]
496    fn test_recent_improvement_empty() {
497        let h = default_history();
498        assert!((h.recent_improvement(5) - 0.0).abs() < 1e-12);
499    }
500
501    #[test]
502    fn test_recent_improvement_one_step() {
503        let mut h = default_history();
504        h.record(make_step(0, 1.0));
505        assert!((h.recent_improvement(5) - 0.0).abs() < 1e-12);
506    }
507
508    #[test]
509    fn test_recent_improvement_full_window() {
510        let mut h = default_history();
511        for i in 0..5u64 {
512            h.record(make_step(i, 5.0 - i as f64));
513        }
514        // First loss=5.0, last loss=1.0 → improvement=4.0 over last 5 steps.
515        assert!((h.recent_improvement(5) - 4.0).abs() < 1e-10);
516    }
517
518    #[test]
519    fn test_recent_improvement_partial_window() {
520        let mut h = default_history();
521        for i in 0..10u64 {
522            h.record(make_step(i, 10.0 - i as f64));
523        }
524        // Last 3 steps: losses 8.0, 9.0... wait, losses are 10-i: step9=1, step8=2, step7=3.
525        // Window of 3: steps[7..10] → losses 3.0, 2.0, 1.0 → improvement = 3.0-1.0 = 2.0
526        assert!((h.recent_improvement(3) - 2.0).abs() < 1e-10);
527    }
528
529    #[test]
530    fn test_recent_improvement_n_larger_than_history() {
531        let mut h = default_history();
532        h.record(make_step(0, 10.0));
533        h.record(make_step(1, 6.0));
534        // n=100 > 2 steps, uses all → improvement = 10.0 - 6.0 = 4.0
535        assert!((h.recent_improvement(100) - 4.0).abs() < 1e-10);
536    }
537
538    #[test]
539    fn test_recent_improvement_negative_when_loss_increases() {
540        let mut h = default_history();
541        h.record(make_step(0, 1.0));
542        h.record(make_step(1, 2.0));
543        // first - last = 1.0 - 2.0 = -1.0 (worsening)
544        assert!((h.recent_improvement(2) - (-1.0)).abs() < 1e-10);
545    }
546
547    // -----------------------------------------------------------------------
548    // 8. avg_gradient_norm
549    // -----------------------------------------------------------------------
550
551    #[test]
552    fn test_avg_gradient_norm_empty() {
553        let h = default_history();
554        assert!((h.avg_gradient_norm() - 0.0).abs() < 1e-12);
555    }
556
557    #[test]
558    fn test_avg_gradient_norm_single() {
559        let mut h = default_history();
560        h.record(make_step_full(0, 1.0, 0.4));
561        assert!((h.avg_gradient_norm() - 0.4).abs() < 1e-12);
562    }
563
564    #[test]
565    fn test_avg_gradient_norm_multiple() {
566        let mut h = default_history();
567        h.record(make_step_full(0, 1.0, 1.0));
568        h.record(make_step_full(1, 0.9, 2.0));
569        h.record(make_step_full(2, 0.8, 3.0));
570        // mean = (1+2+3)/3 = 2.0
571        assert!((h.avg_gradient_norm() - 2.0).abs() < 1e-12);
572    }
573
574    // -----------------------------------------------------------------------
575    // 9. reset
576    // -----------------------------------------------------------------------
577
578    #[test]
579    fn test_reset_clears_steps() {
580        let mut h = default_history();
581        for i in 0..5u64 {
582            h.record(make_step(i, 1.0));
583        }
584        h.reset();
585        assert!(h.steps.is_empty());
586    }
587
588    #[test]
589    fn test_reset_restores_best_loss() {
590        let mut h = default_history();
591        h.record(make_step(0, 0.5));
592        h.reset();
593        assert_eq!(h.best_loss, f64::MAX);
594    }
595
596    #[test]
597    fn test_reset_clears_best_step() {
598        let mut h = default_history();
599        h.record(make_step(7, 0.1));
600        h.reset();
601        assert_eq!(h.best_step, 0);
602    }
603
604    #[test]
605    fn test_reset_clears_consecutive_no_progress() {
606        let config = OptimizationHistoryConfig {
607            max_steps: 1000,
608            convergence_patience: 3,
609            convergence_threshold: 1e-3,
610        };
611        let mut h = TensorOptimizationHistory::new(config);
612        h.record(make_step(0, 1.0));
613        h.record(make_step(1, 1.0 - 1e-9));
614        h.record(make_step(2, 1.0 - 2e-9));
615        h.reset();
616        assert_eq!(h.consecutive_no_progress, 0);
617    }
618
619    #[test]
620    fn test_reset_allows_fresh_recording() {
621        let mut h = default_history();
622        h.record(make_step(0, 5.0));
623        h.reset();
624        h.record(make_step(0, 3.0));
625        assert_eq!(h.steps.len(), 1);
626        assert!((h.best_loss - 3.0).abs() < 1e-12);
627    }
628
629    // -----------------------------------------------------------------------
630    // 10. stats
631    // -----------------------------------------------------------------------
632
633    #[test]
634    fn test_stats_empty() {
635        let h = default_history();
636        let s = h.stats();
637        assert_eq!(s.total_steps, 0);
638        assert_eq!(s.best_loss, f64::MAX);
639        assert_eq!(s.best_step, 0);
640        assert_eq!(s.current_loss, f64::MAX);
641        assert!((s.avg_gradient_norm - 0.0).abs() < 1e-12);
642        assert_eq!(s.convergence_status, ConvergenceStatus::NotConverged);
643    }
644
645    #[test]
646    fn test_stats_correct_values() {
647        let mut h = default_history();
648        h.record(make_step_full(0, 2.0, 1.0));
649        h.record(make_step_full(1, 1.0, 3.0));
650        let s = h.stats();
651        assert_eq!(s.total_steps, 2);
652        assert!((s.best_loss - 1.0).abs() < 1e-12);
653        assert_eq!(s.best_step, 1);
654        assert!((s.current_loss - 1.0).abs() < 1e-12);
655        assert!((s.avg_gradient_norm - 2.0).abs() < 1e-12);
656    }
657
658    // -----------------------------------------------------------------------
659    // 11. last_step
660    // -----------------------------------------------------------------------
661
662    #[test]
663    fn test_last_step_empty() {
664        let h = default_history();
665        assert!(h.last_step().is_none());
666    }
667
668    #[test]
669    fn test_last_step_returns_latest() {
670        let mut h = default_history();
671        h.record(make_step(0, 2.0));
672        h.record(make_step(1, 1.0));
673        let last = h.last_step().expect("should have last step");
674        assert_eq!(last.step, 1);
675        assert!((last.loss - 1.0).abs() < 1e-12);
676    }
677
678    // -----------------------------------------------------------------------
679    // 12. first record initialises best correctly
680    // -----------------------------------------------------------------------
681
682    #[test]
683    fn test_first_record_sets_best() {
684        let mut h = default_history();
685        h.record(make_step(42, 7.5));
686        assert!((h.best_loss - 7.5).abs() < 1e-12);
687        assert_eq!(h.best_step, 42);
688        assert_eq!(h.consecutive_no_progress, 0);
689    }
690
691    // -----------------------------------------------------------------------
692    // 13. ConvergenceStatus derives
693    // -----------------------------------------------------------------------
694
695    #[test]
696    fn test_convergence_status_copy() {
697        let s = ConvergenceStatus::Converged;
698        let t = s; // Copy
699        assert_eq!(s, t);
700    }
701
702    #[test]
703    fn test_convergence_status_debug() {
704        let s = format!("{:?}", ConvergenceStatus::PossiblyConverged);
705        assert_eq!(s, "PossiblyConverged");
706    }
707}