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}