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