Skip to main content

apex_solver/observers/
mod.rs

1//! Observer pattern for optimization monitoring.
2//!
3//! This module provides a clean observer pattern for monitoring optimization progress.
4//! Observers can be registered with any optimizer and will be notified at each iteration,
5//! enabling real-time visualization, logging, metrics collection, and custom analysis.
6//!
7//! # Design Philosophy
8//!
9//! The observer pattern provides complete separation between optimization algorithms
10//! and monitoring/visualization logic:
11//!
12//! - **Decoupling**: Optimization logic is independent of how progress is monitored
13//! - **Extensibility**: Easy to add new observers (Rerun, CSV, metrics, dashboards)
14//! - **Composability**: Multiple observers can run simultaneously
15//! - **Zero overhead**: When no observers are registered, notification is a no-op
16//!
17//! # Architecture
18//!
19//! ```text
20//! ┌─────────────────┐
21//! │   Optimizer     │
22//! │  (LM/GN/DogLeg) │
23//! └────────┬────────┘
24//!          │ observers.notify(values, iteration)
25//!          ├──────────────┬──────────────┬──────────────┐
26//!          ▼              ▼              ▼              ▼
27//!    ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐
28//!    │  Rerun   │  │   CSV    │  │ Metrics  │  │  Custom  │
29//!    │ Observer │  │ Observer │  │ Observer │  │ Observer │
30//!    └──────────┘  └──────────┘  └──────────┘  └──────────┘
31//! ```
32//!
33//! # Examples
34//!
35//! ## Single Observer
36//!
37//! ```no_run
38//! use apex_solver::{LevenbergMarquardt, LevenbergMarquardtConfig};
39//! use apex_solver::observers::OptObserver;
40//! # use apex_solver::core::problem::Problem;
41//! # use apex_solver::JacobianMode;
42//! # use std::collections::HashMap;
43//!
44//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
45//! # let problem = Problem::new(JacobianMode::Sparse);
46//! # let initial_values = HashMap::new();
47//!
48//! let config = LevenbergMarquardtConfig::new().with_max_iterations(100);
49//! let mut solver = LevenbergMarquardt::with_config(config);
50//!
51//! #[cfg(feature = "visualization")]
52//! {
53//!     use apex_solver::observers::RerunObserver;
54//!     let rerun_observer = RerunObserver::new(true)?;
55//!     solver.add_observer(rerun_observer);
56//! }
57//!
58//! let result = solver.optimize(&problem, &initial_values)?;
59//! # Ok(())
60//! # }
61//! ```
62//!
63//! ## Multiple Observers
64//!
65//! ```no_run
66//! # use apex_solver::{LevenbergMarquardt, LevenbergMarquardtConfig};
67//! # use apex_solver::core::problem::{Problem, VariableEnum};
68//! # use apex_solver::observers::OptObserver;
69//! # use apex_solver::JacobianMode;
70//! # use std::collections::HashMap;
71//!
72//! // Custom observer that logs to CSV
73//! struct CsvObserver {
74//!     file: std::fs::File,
75//! }
76//!
77//! impl OptObserver for CsvObserver {
78//!     fn on_step(&self, _values: &HashMap<String, VariableEnum>, iteration: usize) {
79//!         // Write iteration data to CSV
80//!         // ... implementation ...
81//!     }
82//! }
83//!
84//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
85//! # let problem = Problem::new(JacobianMode::Sparse);
86//! # let initial_values = HashMap::new();
87//! let mut solver = LevenbergMarquardt::new();
88//!
89//! // Add Rerun visualization
90//! #[cfg(feature = "visualization")]
91//! {
92//!     use apex_solver::observers::RerunObserver;
93//!     solver.add_observer(RerunObserver::new(true)?);
94//! }
95//!
96//! // Add CSV logging
97//! // solver.add_observer(CsvObserver { file: ... });
98//!
99//! let result = solver.optimize(&problem, &initial_values)?;
100//! # Ok(())
101//! # }
102//! ```
103//!
104//! ## Custom Observer
105//!
106//! ```no_run
107//! use apex_solver::observers::OptObserver;
108//! use apex_solver::core::problem::VariableEnum;
109//! use std::collections::HashMap;
110//!
111//! struct MetricsObserver {
112//!     max_variables_seen: std::cell::RefCell<usize>,
113//! }
114//!
115//! impl OptObserver for MetricsObserver {
116//!     fn on_step(&self, values: &HashMap<String, VariableEnum>, iteration: usize) {
117//!         let count = values.len();
118//!         let mut max = self.max_variables_seen.borrow_mut();
119//!         *max = (*max).max(count);
120//!     }
121//! }
122//! ```
123
124// Visualization-specific submodules (feature-gated)
125#[cfg(feature = "visualization")]
126pub mod conversions;
127#[cfg(feature = "visualization")]
128pub mod visualization;
129
130// Re-export RerunObserver when visualization is enabled
131#[cfg(feature = "visualization")]
132pub use visualization::{RerunObserver, VisualizationConfig, VisualizationMode};
133
134// Re-export conversion traits for ergonomic use
135#[cfg(feature = "visualization")]
136pub use conversions::{CollectRerun2D, CollectRerun3D, RerunConvert2D, RerunConvert3D};
137
138use crate::core::problem::VariableEnum;
139use faer::Mat;
140use faer::sparse;
141use std::collections::HashMap;
142use thiserror::Error;
143
144/// Observer-specific error types for apex-solver
145#[derive(Debug, Clone, Error)]
146pub enum ObserverError {
147    /// Failed to initialize Rerun recording stream
148    #[error("Failed to initialize Rerun recording stream: {0}")]
149    RerunInitialization(String),
150
151    /// Failed to spawn Rerun viewer process
152    #[error("Failed to spawn Rerun viewer: {0}")]
153    ViewerSpawnFailed(String),
154
155    /// Failed to save recording to file
156    #[error("Failed to save recording to file '{path}': {reason}")]
157    RecordingSaveFailed { path: String, reason: String },
158
159    /// Failed to log data to Rerun
160    #[error("Failed to log data to Rerun at '{entity_path}': {reason}")]
161    LoggingFailed { entity_path: String, reason: String },
162
163    /// Failed to convert matrix to visualization format
164    #[error("Failed to convert matrix to image: {0}")]
165    MatrixVisualizationFailed(String),
166
167    /// Failed to convert tensor data
168    #[error("Failed to create tensor data: {0}")]
169    TensorConversionFailed(String),
170
171    /// Recording stream is in invalid state
172    #[error("Recording stream is in invalid state: {0}")]
173    InvalidState(String),
174
175    /// Mutex was poisoned (thread panicked while holding lock)
176    #[error("Mutex poisoned in {context}: {reason}")]
177    MutexPoisoned { context: String, reason: String },
178}
179
180/// Result type for observer operations
181pub type ObserverResult<T> = Result<T, ObserverError>;
182
183/// Observer trait for monitoring optimization progress.
184///
185/// Implement this trait to create custom observers that are notified at each
186/// optimization iteration. Observers receive the current variable values and
187/// iteration number, enabling real-time monitoring, visualization, logging,
188/// or custom analysis.
189///
190/// # Design Notes
191///
192/// - Observers should be lightweight and non-blocking
193/// - Errors in observers should not crash optimization (handle internally)
194/// - For expensive operations (file I/O, network), consider buffering
195/// - Observers receive immutable references (cannot modify optimization state)
196///
197/// # Thread Safety
198///
199/// Observers must be `Send` to support parallel optimization in the future.
200/// Use interior mutability (`RefCell`, `Mutex`) if you need to mutate state.
201pub trait OptObserver: Send {
202    /// Called after each optimization iteration.
203    ///
204    /// # Arguments
205    ///
206    /// * `values` - Current variable values (manifold states)
207    /// * `iteration` - Current iteration number (0 = initial values, 1+ = after steps)
208    ///
209    /// # Implementation Guidelines
210    ///
211    /// - Keep this method fast to avoid slowing optimization
212    /// - Handle errors internally (log warnings, don't panic)
213    /// - Don't mutate `values` (you receive `&HashMap`)
214    /// - Consider buffering expensive operations
215    ///
216    /// # Examples
217    ///
218    /// ```no_run
219    /// use apex_solver::observers::OptObserver;
220    /// use apex_solver::core::problem::VariableEnum;
221    /// use std::collections::HashMap;
222    ///
223    /// struct SimpleLogger;
224    ///
225    /// impl OptObserver for SimpleLogger {
226    ///     fn on_step(&self, values: &HashMap<String, VariableEnum>, iteration: usize) {
227    ///         // Track optimization progress
228    ///     }
229    /// }
230    /// ```
231    fn on_step(&self, values: &HashMap<String, VariableEnum>, iteration: usize);
232
233    /// Set iteration metrics for visualization and monitoring.
234    ///
235    /// This method is called before `on_step` to provide optimization metrics
236    /// such as cost, gradient norm, damping parameter, etc. Observers can use
237    /// this data for visualization, logging, or analysis.
238    ///
239    /// # Arguments
240    ///
241    /// * `cost` - Current cost function value
242    /// * `gradient_norm` - L2 norm of the gradient vector
243    /// * `damping` - Damping parameter (for Levenberg-Marquardt, may be None for other solvers)
244    /// * `step_norm` - L2 norm of the parameter update step
245    /// * `step_quality` - Step quality metric (e.g., rho for trust region methods)
246    ///
247    /// # Default Implementation
248    ///
249    /// The default implementation does nothing, allowing simple observers to ignore metrics.
250    fn set_iteration_metrics(
251        &self,
252        _cost: f64,
253        _gradient_norm: f64,
254        _damping: Option<f64>,
255        _step_norm: f64,
256        _step_quality: Option<f64>,
257    ) {
258        // Default implementation does nothing
259    }
260
261    /// Set matrix data for advanced visualization.
262    ///
263    /// This method provides access to the Hessian matrix and gradient vector
264    /// for observers that want to visualize matrix structure or perform
265    /// advanced analysis.
266    ///
267    /// # Arguments
268    ///
269    /// * `hessian` - Sparse Hessian matrix (J^T * J)
270    /// * `gradient` - Gradient vector (J^T * r)
271    ///
272    /// # Default Implementation
273    ///
274    /// The default implementation does nothing, allowing simple observers to ignore matrices.
275    fn set_matrix_data(
276        &self,
277        _hessian: Option<sparse::SparseColMat<usize, f64>>,
278        _gradient: Option<Mat<f64>>,
279    ) {
280        // Default implementation does nothing
281    }
282
283    /// Called when optimization completes.
284    ///
285    /// This method is called once at the end of optimization, after all iterations
286    /// are complete. Use this for final visualization, cleanup, or summary logging.
287    ///
288    /// # Arguments
289    ///
290    /// * `values` - Final optimized variable values
291    /// * `iterations` - Total number of iterations performed
292    ///
293    /// # Default Implementation
294    ///
295    /// The default implementation does nothing, allowing simple observers to ignore completion.
296    ///
297    /// # Examples
298    ///
299    /// ```no_run
300    /// use apex_solver::observers::OptObserver;
301    /// use apex_solver::core::problem::VariableEnum;
302    /// use std::collections::HashMap;
303    ///
304    /// struct FinalStateLogger;
305    ///
306    /// impl OptObserver for FinalStateLogger {
307    ///     fn on_step(&self, _values: &HashMap<String, VariableEnum>, _iteration: usize) {}
308    ///
309    ///     fn on_optimization_complete(&self, values: &HashMap<String, VariableEnum>, iterations: usize) {
310    ///         println!("Optimization completed after {} iterations with {} variables",
311    ///                  iterations, values.len());
312    ///     }
313    /// }
314    /// ```
315    fn on_optimization_complete(
316        &self,
317        _values: &HashMap<String, VariableEnum>,
318        _iterations: usize,
319    ) {
320        // Default implementation does nothing
321    }
322}
323
324/// Collection of observers for optimization monitoring.
325///
326/// This struct manages a vector of observers and provides a convenient
327/// `notify()` method to call all observers at once. Optimizers use this
328/// internally to manage their observers.
329///
330/// # Usage
331///
332/// Typically you don't create this directly - use the `add_observer()` method
333/// on optimizers. However, you can use it for custom optimization algorithms:
334///
335/// ```no_run
336/// use apex_solver::observers::{OptObserver, OptObserverVec};
337/// use apex_solver::core::problem::VariableEnum;
338/// use std::collections::HashMap;
339///
340/// struct MyOptimizer {
341///     observers: OptObserverVec,
342///     // ... other fields ...
343/// }
344///
345/// impl MyOptimizer {
346///     fn step(&mut self, values: &HashMap<String, VariableEnum>, iteration: usize) {
347///         // ... optimization logic ...
348///
349///         // Notify all observers
350///         self.observers.notify(values, iteration);
351///     }
352/// }
353/// ```
354#[derive(Default)]
355pub struct OptObserverVec {
356    observers: Vec<Box<dyn OptObserver>>,
357}
358
359impl OptObserverVec {
360    /// Create a new empty observer collection.
361    pub fn new() -> Self {
362        Self {
363            observers: Vec::new(),
364        }
365    }
366
367    /// Add an observer to the collection.
368    ///
369    /// The observer will be called at each optimization iteration in the order
370    /// it was added.
371    ///
372    /// # Arguments
373    ///
374    /// * `observer` - Any type implementing `OptObserver`
375    ///
376    /// # Examples
377    ///
378    /// ```no_run
379    /// use apex_solver::observers::{OptObserver, OptObserverVec};
380    /// use apex_solver::core::problem::VariableEnum;
381    /// use std::collections::HashMap;
382    ///
383    /// struct MyObserver;
384    /// impl OptObserver for MyObserver {
385    ///     fn on_step(&self, _values: &HashMap<String, VariableEnum>, _iteration: usize) {
386    ///         // Handle optimization step
387    ///     }
388    /// }
389    ///
390    /// let mut observers = OptObserverVec::new();
391    /// observers.add(MyObserver);
392    /// ```
393    pub fn add(&mut self, observer: impl OptObserver + 'static) {
394        self.observers.push(Box::new(observer));
395    }
396
397    /// Set iteration metrics for all observers.
398    ///
399    /// Calls `set_iteration_metrics()` on each registered observer. This should
400    /// be called before `notify()` to provide optimization metrics.
401    ///
402    /// # Arguments
403    ///
404    /// * `cost` - Current cost function value
405    /// * `gradient_norm` - L2 norm of the gradient vector
406    /// * `damping` - Damping parameter (may be None)
407    /// * `step_norm` - L2 norm of the parameter update step
408    /// * `step_quality` - Step quality metric (may be None)
409    #[inline]
410    pub fn set_iteration_metrics(
411        &self,
412        cost: f64,
413        gradient_norm: f64,
414        damping: Option<f64>,
415        step_norm: f64,
416        step_quality: Option<f64>,
417    ) {
418        for observer in &self.observers {
419            observer.set_iteration_metrics(cost, gradient_norm, damping, step_norm, step_quality);
420        }
421    }
422
423    /// Set matrix data for all observers.
424    ///
425    /// Calls `set_matrix_data()` on each registered observer. This should
426    /// be called before `notify()` to provide matrix data for visualization.
427    ///
428    /// # Arguments
429    ///
430    /// * `hessian` - Sparse Hessian matrix
431    /// * `gradient` - Gradient vector
432    #[inline]
433    pub fn set_matrix_data(
434        &self,
435        hessian: Option<sparse::SparseColMat<usize, f64>>,
436        gradient: Option<Mat<f64>>,
437    ) {
438        for observer in &self.observers {
439            observer.set_matrix_data(hessian.clone(), gradient.clone());
440        }
441    }
442
443    /// Notify all observers with current optimization state.
444    ///
445    /// Calls `on_step()` on each registered observer in order. If no observers
446    /// are registered, this is a no-op with zero overhead.
447    ///
448    /// # Arguments
449    ///
450    /// * `values` - Current variable values
451    /// * `iteration` - Current iteration number
452    ///
453    /// # Examples
454    ///
455    /// ```no_run
456    /// use apex_solver::observers::OptObserverVec;
457    /// use std::collections::HashMap;
458    ///
459    /// let observers = OptObserverVec::new();
460    /// let values = HashMap::new();
461    ///
462    /// // Notify all observers (safe even if empty)
463    /// observers.notify(&values, 0);
464    /// ```
465    #[inline]
466    pub fn notify(&self, values: &HashMap<String, VariableEnum>, iteration: usize) {
467        for observer in &self.observers {
468            observer.on_step(values, iteration);
469        }
470    }
471
472    /// Notify all observers that optimization is complete.
473    ///
474    /// Calls `on_optimization_complete()` on each registered observer. This should
475    /// be called once at the end of optimization, after all iterations are done.
476    ///
477    /// # Arguments
478    ///
479    /// * `values` - Final optimized variable values
480    /// * `iterations` - Total number of iterations performed
481    ///
482    /// # Examples
483    ///
484    /// ```no_run
485    /// use apex_solver::observers::OptObserverVec;
486    /// use std::collections::HashMap;
487    ///
488    /// let observers = OptObserverVec::new();
489    /// let values = HashMap::new();
490    ///
491    /// // Notify all observers that optimization is complete
492    /// observers.notify_complete(&values, 50);
493    /// ```
494    #[inline]
495    pub fn notify_complete(&self, values: &HashMap<String, VariableEnum>, iterations: usize) {
496        for observer in &self.observers {
497            observer.on_optimization_complete(values, iterations);
498        }
499    }
500
501    /// Check if any observers are registered.
502    ///
503    /// Useful for conditional logic or debugging.
504    #[inline]
505    pub fn is_empty(&self) -> bool {
506        self.observers.is_empty()
507    }
508
509    /// Get the number of registered observers.
510    #[inline]
511    pub fn len(&self) -> usize {
512        self.observers.len()
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::error::ErrorLogging;
520    use std::sync::{Arc, Mutex};
521
522    #[derive(Clone)]
523    struct TestObserver {
524        calls: Arc<Mutex<Vec<usize>>>,
525    }
526
527    impl OptObserver for TestObserver {
528        fn on_step(&self, _values: &HashMap<String, VariableEnum>, iteration: usize) {
529            // In test code, we log and ignore mutex poisoning errors since they indicate test bugs
530            if let Ok(mut guard) = self.calls.lock().map_err(|e| {
531                ObserverError::MutexPoisoned {
532                    context: "TestObserver::on_step".to_string(),
533                    reason: e.to_string(),
534                }
535                .log()
536            }) {
537                guard.push(iteration);
538            }
539        }
540    }
541
542    #[test]
543    fn test_empty_observers() {
544        let observers = OptObserverVec::new();
545        assert!(observers.is_empty());
546        assert_eq!(observers.len(), 0);
547
548        // Should not panic with no observers
549        observers.notify(&HashMap::new(), 0);
550    }
551
552    #[test]
553    fn test_single_observer() -> Result<(), ObserverError> {
554        let calls = Arc::new(Mutex::new(Vec::new()));
555        let observer = TestObserver {
556            calls: calls.clone(),
557        };
558
559        let mut observers = OptObserverVec::new();
560        observers.add(observer);
561
562        assert_eq!(observers.len(), 1);
563
564        observers.notify(&HashMap::new(), 0);
565        observers.notify(&HashMap::new(), 1);
566        observers.notify(&HashMap::new(), 2);
567
568        let guard = calls.lock().map_err(|e| {
569            ObserverError::MutexPoisoned {
570                context: "test_single_observer".to_string(),
571                reason: e.to_string(),
572            }
573            .log()
574        })?;
575        assert_eq!(*guard, vec![0, 1, 2]);
576        Ok(())
577    }
578
579    #[test]
580    fn test_multiple_observers() -> Result<(), ObserverError> {
581        let calls1 = Arc::new(Mutex::new(Vec::new()));
582        let calls2 = Arc::new(Mutex::new(Vec::new()));
583
584        let observer1 = TestObserver {
585            calls: calls1.clone(),
586        };
587        let observer2 = TestObserver {
588            calls: calls2.clone(),
589        };
590
591        let mut observers = OptObserverVec::new();
592        observers.add(observer1);
593        observers.add(observer2);
594
595        assert_eq!(observers.len(), 2);
596
597        observers.notify(&HashMap::new(), 5);
598
599        let guard1 = calls1.lock().map_err(|e| {
600            ObserverError::MutexPoisoned {
601                context: "test_multiple_observers (calls1)".to_string(),
602                reason: e.to_string(),
603            }
604            .log()
605        })?;
606        assert_eq!(*guard1, vec![5]);
607
608        let guard2 = calls2.lock().map_err(|e| {
609            ObserverError::MutexPoisoned {
610                context: "test_multiple_observers (calls2)".to_string(),
611                reason: e.to_string(),
612            }
613            .log()
614        })?;
615        assert_eq!(*guard2, vec![5]);
616        Ok(())
617    }
618
619    // -------------------------------------------------------------------------
620    // ObserverError Display — one per variant
621    // -------------------------------------------------------------------------
622
623    #[test]
624    fn test_observer_error_rerun_initialization_display() {
625        let e = ObserverError::RerunInitialization("init fail".into());
626        assert!(e.to_string().contains("init fail"));
627    }
628
629    #[test]
630    fn test_observer_error_viewer_spawn_failed_display() {
631        let e = ObserverError::ViewerSpawnFailed("spawn fail".into());
632        assert!(e.to_string().contains("spawn fail"));
633    }
634
635    #[test]
636    fn test_observer_error_recording_save_failed_display() {
637        let e = ObserverError::RecordingSaveFailed {
638            path: "/tmp/out.rrd".into(),
639            reason: "disk full".into(),
640        };
641        let s = e.to_string();
642        assert!(s.contains("/tmp/out.rrd"), "{s}");
643        assert!(s.contains("disk full"), "{s}");
644    }
645
646    #[test]
647    fn test_observer_error_logging_failed_display() {
648        let e = ObserverError::LoggingFailed {
649            entity_path: "world/points".into(),
650            reason: "timeout".into(),
651        };
652        let s = e.to_string();
653        assert!(s.contains("world/points"), "{s}");
654        assert!(s.contains("timeout"), "{s}");
655    }
656
657    #[test]
658    fn test_observer_error_matrix_visualization_failed_display() {
659        let e = ObserverError::MatrixVisualizationFailed("bad dims".into());
660        assert!(e.to_string().contains("bad dims"));
661    }
662
663    #[test]
664    fn test_observer_error_tensor_conversion_failed_display() {
665        let e = ObserverError::TensorConversionFailed("nan values".into());
666        assert!(e.to_string().contains("nan values"));
667    }
668
669    #[test]
670    fn test_observer_error_invalid_state_display() {
671        let e = ObserverError::InvalidState("stream closed".into());
672        assert!(e.to_string().contains("stream closed"));
673    }
674
675    #[test]
676    fn test_observer_error_mutex_poisoned_display() {
677        let e = ObserverError::MutexPoisoned {
678            context: "on_step".into(),
679            reason: "thread panicked".into(),
680        };
681        let s = e.to_string();
682        assert!(s.contains("on_step"), "{s}");
683        assert!(s.contains("thread panicked"), "{s}");
684    }
685
686    // -------------------------------------------------------------------------
687    // log() / log_with_source() return self
688    // -------------------------------------------------------------------------
689
690    #[test]
691    fn test_observer_error_log_returns_self() {
692        let e = ObserverError::InvalidState("log_test".into());
693        let returned = e.log();
694        assert!(returned.to_string().contains("log_test"));
695    }
696
697    #[test]
698    fn test_observer_error_log_with_source_returns_self() {
699        let e = ObserverError::MatrixVisualizationFailed("src_test".into());
700        let source = std::io::Error::other("src");
701        let returned = e.log_with_source(source);
702        assert!(returned.to_string().contains("src_test"));
703    }
704
705    // -------------------------------------------------------------------------
706    // OptObserverVec — set_iteration_metrics, set_matrix_data, notify_complete
707    // -------------------------------------------------------------------------
708
709    #[test]
710    fn test_set_iteration_metrics_no_panic() {
711        let mut observers = OptObserverVec::new();
712        observers.add(TestObserver {
713            calls: Arc::new(Mutex::new(Vec::new())),
714        });
715        // Should not panic whether empty or not
716        observers.set_iteration_metrics(1.5, 1e-3, Some(1e-4), 0.01, Some(0.9));
717    }
718
719    #[test]
720    fn test_set_iteration_metrics_empty_no_panic() {
721        let observers = OptObserverVec::new();
722        observers.set_iteration_metrics(0.0, 0.0, None, 0.0, None);
723    }
724
725    #[test]
726    fn test_set_matrix_data_no_panic() {
727        let mut observers = OptObserverVec::new();
728        observers.add(TestObserver {
729            calls: Arc::new(Mutex::new(Vec::new())),
730        });
731        // Pass None for both hessian and gradient
732        observers.set_matrix_data(None, None);
733    }
734
735    // Observer that counts on_optimization_complete calls
736    #[derive(Clone)]
737    struct CompleteObserver {
738        complete_calls: Arc<Mutex<usize>>,
739    }
740
741    impl OptObserver for CompleteObserver {
742        fn on_step(&self, _values: &HashMap<String, VariableEnum>, _iteration: usize) {}
743
744        fn on_optimization_complete(
745            &self,
746            _values: &HashMap<String, VariableEnum>,
747            _iterations: usize,
748        ) {
749            if let Ok(mut guard) = self.complete_calls.lock() {
750                *guard += 1;
751            }
752        }
753    }
754
755    #[test]
756    fn test_notify_complete_calls_on_optimization_complete() {
757        let complete_calls = Arc::new(Mutex::new(0usize));
758        let observer = CompleteObserver {
759            complete_calls: complete_calls.clone(),
760        };
761
762        let mut observers = OptObserverVec::new();
763        observers.add(observer);
764        observers.notify_complete(&HashMap::new(), 10);
765
766        let count = *complete_calls.lock().unwrap_or_else(|e| e.into_inner());
767        assert_eq!(count, 1);
768    }
769
770    #[test]
771    fn test_notify_complete_empty_no_panic() {
772        let observers = OptObserverVec::new();
773        observers.notify_complete(&HashMap::new(), 5);
774    }
775
776    #[test]
777    fn test_default_trait_methods_no_panic() {
778        // TestObserver only overrides on_step; the default impls are exercised here
779        let observer = TestObserver {
780            calls: Arc::new(Mutex::new(Vec::new())),
781        };
782        // Default implementations should be no-ops
783        observer.set_iteration_metrics(1.0, 1e-3, None, 0.0, None);
784        observer.set_matrix_data(None, None);
785        observer.on_optimization_complete(&HashMap::new(), 5);
786    }
787}