globalsearch 0.5.0

A multistart framework for global optimization with scatter search and local NLP solvers written in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
//! Stage 2 observer state tracking
//!
//! Tracks metrics during Stage 2 (iterative improvement phase).
//! Stage 2 performs iterative refinement of the solution set through
//! merit filtering, distance-based selection, and local optimization.
//!
//! ## Stage 2 Overview
//!
//! Stage 2 iteratively improves the solution set by:
//!
//! 1. **Merit Filtering**: Select best solutions based on quality and diversity
//! 2. **Trial Point Generation**: Create new candidates from current solutions
//! 3. **Local Optimization**: Apply local solvers to improve candidate solutions
//! 4. **Solution Set Update**: Replace inferior solutions with improvements
//! 5. **Convergence Check**: Monitor for termination conditions
//!
//! ## Tracked Metrics
//!
//! - **Solution Set**: Size and quality of the maintained solution collection
//! - **Iterations**: Progress through the iterative improvement process
//! - **Local Solver Calls**: Frequency and success of local optimization
//! - **Function Evaluations**: Computational cost of Stage 2
//! - **Convergence Metrics**: Threshold values and stagnation detection
//! - **Timing**: Duration of Stage 2 execution (when enabled)

use std::time::Instant;

/// State tracker for Stage 2 of the algorithm
///
/// Tracks comprehensive metrics during the iterative refinement phase that
/// improves the solution set through merit filtering and local optimization.
/// This phase focuses on intensifying search around high-quality regions.
///
/// # Key Metrics
///
/// - **Best Objective**: Best objective function value found across all solutions
/// - **Solution Set Size**: Number of solutions maintained in the working set
/// - **Current Iteration**: Progress through the iterative improvement process
/// - **Threshold Value**: Merit filter threshold for solution acceptance
/// - **Local Solver Calls**: Total calls made to local optimization algorithms
/// - **Improved Calls**: Number of local solver calls that improved solutions
/// - **Function Evaluations**: Total objective function calls in Stage 2
/// - **Unchanged Cycles**: Number of iterations without solution improvement
/// - **Timing**: Total duration of Stage 2 execution (when enabled)
///
/// # Interpretation
///
/// - **Best Objective**: Overall quality of solutions found
/// - **Solution Set Size**: Diversity and coverage maintained
/// - **Local Solver Calls**: Intensity of local search effort
/// - **Unchanged Cycles**: Convergence indicator (increasing suggests stagnation)
/// - **Threshold Value**: Adaptivity of merit filtering (lower = more selective)
#[derive(Debug, Clone)]
pub struct Stage2State {
    /// Best objective function value found so far
    best_objective: f64,

    /// Best solution coordinates found so far
    best_point: Option<ndarray::Array1<f64>>,

    /// Last solution added to the solution set (for tracking new discoveries)
    last_added_point: Option<ndarray::Array1<f64>>,

    /// Current number of solutions in solution set
    solution_set_size: usize,

    /// Current iteration number
    current_iteration: usize,

    /// Current threshold value for merit filter
    threshold_value: f64,

    /// Number of local solver calls made
    local_solver_calls: usize,

    /// Number of local solver calls that improved the solution set
    improved_local_calls: usize,

    /// Total number of function evaluations in this stage (includes trial points and local solver evaluations)
    function_evaluations: usize,

    /// Number of unchanged cycles
    unchanged_cycles: usize,

    /// Total time spent in Stage 2
    total_time: Option<f64>,

    /// Start time for Stage 2
    stage_start: Option<Instant>,
}

impl Stage2State {
    /// Create a new Stage 2 state tracker
    ///
    /// Initializes all metrics to default values:
    /// - Best objective: NaN (no valid solutions yet)
    /// - Solution set size: 0
    /// - Current iteration: 0
    /// - Threshold value: +∞ (accept all initially)
    /// - Local solver calls: 0
    /// - Improved calls: 0
    /// - Function evaluations: 0
    /// - Unchanged cycles: 0
    /// - Timing: None
    pub fn new() -> Self {
        Self {
            best_objective: f64::NAN,
            best_point: None,
            last_added_point: None,
            solution_set_size: 0,
            current_iteration: 0,
            threshold_value: f64::INFINITY,
            local_solver_calls: 0,
            improved_local_calls: 0,
            function_evaluations: 0,
            unchanged_cycles: 0,
            total_time: None,
            stage_start: None,
        }
    }

    /// Start Stage 2 timing
    pub fn start(&mut self) {
        self.stage_start = Some(Instant::now());
    }

    /// End Stage 2 and calculate total time
    pub fn end(&mut self) {
        if let Some(start) = self.stage_start {
            self.total_time = Some(start.elapsed().as_secs_f64());
        }
    }

    /// Update current iteration
    pub fn set_iteration(&mut self, iteration: usize) {
        self.current_iteration = iteration;
    }

    /// Update best objective value
    pub fn set_best_objective(&mut self, objective: f64) {
        if self.best_objective.is_nan() || objective < self.best_objective {
            self.best_objective = objective;
        }
    }

    /// Update best solution with both objective and coordinates
    pub fn set_best_solution(&mut self, objective: f64, point: &ndarray::Array1<f64>) {
        if self.best_objective.is_nan() || objective < self.best_objective {
            self.best_objective = objective;
            self.best_point = Some(point.clone());
        }
    }

    /// Update solution set size
    pub fn set_solution_set_size(&mut self, size: usize) {
        self.solution_set_size = size;
    }

    /// Update threshold value
    pub fn set_threshold_value(&mut self, threshold: f64) {
        self.threshold_value = threshold;
    }

    /// Increment local solver call counter
    pub fn add_local_solver_call(&mut self, improved: bool) {
        self.local_solver_calls += 1;
        if improved {
            self.improved_local_calls += 1;
        }
    }

    /// Add function evaluations (from trial points or local solvers)
    pub fn add_function_evaluations(&mut self, count: usize) {
        self.function_evaluations += count;
    }

    /// Set unchanged cycles count
    pub fn set_unchanged_cycles(&mut self, count: usize) {
        self.unchanged_cycles = count;
    }

    /// Update the last solution added to the solution set
    ///
    /// This tracks the most recently added solution, which is useful for
    /// monitoring new discoveries in multimodal optimization problems.
    pub fn set_last_added_solution(&mut self, point: &ndarray::Array1<f64>) {
        self.last_added_point = Some(point.clone());
    }

    /// Get best objective value
    ///
    /// Returns the best (lowest) objective function value found across all
    /// solutions in the current solution set. This represents the highest
    /// quality solution discovered during Stage 2.
    ///
    /// # Returns
    ///
    /// - `f64`: Best objective value in the current solution set
    /// - `NaN`: If no valid solutions exist in the solution set
    ///
    /// # Interpretation
    ///
    /// - **Decreasing values**: Algorithm is finding better solutions (good)
    /// - **Stable values**: Algorithm has converged or is exploring
    /// - **NaN**: Solution set is empty or uninitialized
    pub fn best_objective(&self) -> f64 {
        self.best_objective
    }

    /// Get solution set size
    ///
    /// Returns the current number of solutions maintained in the working solution set.
    /// The solution set maintains a diverse collection of high-quality solutions
    /// that balance quality and coverage of the search space.
    ///
    /// # Interpretation
    ///
    /// - **Stable values**: Algorithm maintaining target solution set size
    /// - **Increasing values**: Solution set growing (may indicate exploration)
    /// - **Decreasing values**: Solutions being filtered out (may indicate intensification)
    /// - **Zero**: Solution set is empty (algorithm may have issues)
    pub fn solution_set_size(&self) -> usize {
        self.solution_set_size
    }

    /// Get current iteration
    ///
    /// Returns the current iteration number in Stage 2. Each iteration represents
    /// a complete cycle of selection, generation, evaluation, and filtering.
    ///
    /// # Interpretation
    ///
    /// - **Increasing values**: Algorithm progressing through Stage 2
    /// - **Higher values**: More computational effort invested
    /// - **Zero**: Stage 2 hasn't started or is initializing
    ///
    /// # Relationship to Termination
    ///
    /// The algorithm terminates when either:
    /// - Maximum iterations reached
    /// - Convergence criteria met (unchanged cycles exceed limit)
    /// - Target objective achieved
    pub fn current_iteration(&self) -> usize {
        self.current_iteration
    }

    /// Get threshold value
    ///
    /// Returns the current merit filter threshold value. Solutions must have
    /// an objective value better than this threshold to be accepted into the
    /// solution set during filtering operations.
    ///
    /// # Interpretation
    ///
    /// - **Lower values**: More selective filtering (higher quality requirement)
    /// - **Higher values**: Less selective filtering (accepts more solutions)
    /// - **∞ (infinity)**: Accept all solutions (initial state)
    /// - **Decreasing over time**: Algorithm becoming more selective as it improves
    ///
    /// # Merit Filtering
    ///
    /// The threshold controls the trade-off between solution quality and diversity.
    /// Lower thresholds maintain higher quality solutions but may reduce diversity.
    pub fn threshold_value(&self) -> f64 {
        self.threshold_value
    }

    /// Get number of local solver calls
    ///
    /// Returns the total number of times local optimization algorithms have been
    /// invoked during Stage 2. Each call attempts to improve a candidate solution
    /// through gradient-based or derivative-free local search.
    ///
    /// # Interpretation
    ///
    /// - **Higher values**: More intensive local search effort
    /// - **Increasing over time**: Algorithm actively applying local optimization
    /// - **Computational cost**: Local solver calls are typically expensive
    ///
    /// # Relationship to Improvements
    ///
    /// Compare with `improved_local_calls()` to assess local solver effectiveness.
    /// A high ratio of improved to total calls indicates efficient local search.
    pub fn local_solver_calls(&self) -> usize {
        self.local_solver_calls
    }

    /// Get number of local solver calls that improved the solution set
    ///
    /// Returns the number of local solver calls that successfully improved the
    /// solution set by finding better solutions. This measures the effectiveness
    /// of local optimization in finding improvements.
    ///
    /// # Interpretation
    ///
    /// - **Higher values**: Local solvers frequently finding improvements
    /// - **Ratio to total calls**: Efficiency of local search (improved/total)
    /// - **Increasing over time**: Local solvers still effective
    /// - **Stable/low values**: Local solvers not finding significant improvements
    ///
    /// # Success Metrics
    ///
    /// - **High ratio (>50%)**: Local solvers very effective
    /// - **Moderate ratio (20-50%)**: Local solvers moderately effective
    /// - **Low ratio (<20%)**: Local solvers rarely improving (may indicate convergence)
    pub fn improved_local_calls(&self) -> usize {
        self.improved_local_calls
    }

    /// Get total function evaluations
    ///
    /// Returns the cumulative count of objective function evaluations performed
    /// during Stage 2. This includes evaluations for trial points generated
    /// during each iteration and function evaluations performed by local solvers.
    ///
    /// # Interpretation
    ///
    /// - **Higher values**: More thorough exploration and local optimization
    /// - **Increasing over time**: Algorithm actively evaluating candidates
    /// - **Computational cost**: Primary measure of Stage 2 resource usage
    ///
    /// # Components
    ///
    /// Function evaluations include:
    /// - Trial point evaluations during each iteration
    /// - Local solver function evaluations (gradient computations, line searches, etc.)
    pub fn function_evaluations(&self) -> usize {
        self.function_evaluations
    }

    /// Get unchanged cycles count
    ///
    /// Returns the number of consecutive iterations where the solution set
    /// has not improved. This is a key convergence indicator used to detect
    /// when the algorithm should terminate due to stagnation.
    pub fn unchanged_cycles(&self) -> usize {
        self.unchanged_cycles
    }

    /// Get total time spent in Stage 2 (seconds)
    ///
    /// Returns the time elapsed since Stage 2 began. If Stage 2 is still running,
    /// returns the current elapsed time. If Stage 2 has completed, returns the
    /// total time spent in Stage 2.
    ///
    /// # Returns
    ///
    /// - `Some(f64)`: Elapsed time in seconds
    /// - `None`: If Stage 2 timing was not started
    pub fn total_time(&self) -> Option<f64> {
        if let Some(start) = self.stage_start {
            Some(start.elapsed().as_secs_f64())
        } else {
            self.total_time
        }
    }

    /// Get the best solution coordinates found so far
    ///
    /// Returns the parameter values of the best solution found during Stage 2.
    /// This corresponds to the point that achieved the best objective value.
    ///
    /// # Returns
    ///
    /// - `Some(&Array1<f64>)`: The coordinates of the best solution
    /// - `None`: If no valid solution has been found yet
    ///
    /// # Example
    ///
    /// ```rust
    /// use globalsearch::observers::Observer;
    ///
    /// let observer = Observer::new().with_stage2_tracking();
    ///
    /// // During or after optimization
    /// if let Some(stage2) = observer.stage2() {
    ///     if let Some(point) = stage2.best_point() {
    ///         println!("Best solution coordinates: {:?}", point);
    ///         println!("Best objective value: {}", stage2.best_objective());
    ///     }
    /// }
    /// ```
    pub fn best_point(&self) -> Option<&ndarray::Array1<f64>> {
        self.best_point.as_ref()
    }

    /// Get the last solution added to the solution set
    ///
    /// Returns the parameter values of the most recently added solution.
    /// This is particularly useful for multimodal optimization problems where
    /// you want to track new discoveries rather than always showing the best solution.
    ///
    /// # Returns
    ///
    /// - `Some(&Array1<f64>)`: The coordinates of the last added solution
    /// - `None`: If no solution has been added yet
    ///
    /// # Example
    ///
    /// ```rust
    /// use globalsearch::observers::Observer;
    ///
    /// let observer = Observer::new().with_stage2_tracking();
    ///
    /// // During or after optimization
    /// if let Some(stage2) = observer.stage2() {
    ///     if let Some(point) = stage2.last_added_point() {
    ///         println!("Last added solution coordinates: {:?}", point);
    ///     }
    /// }
    /// ```
    pub fn last_added_point(&self) -> Option<&ndarray::Array1<f64>> {
        self.last_added_point.as_ref()
    }
}

impl Default for Stage2State {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests_observers_stage2 {
    use super::*;

    #[test]
    /// Test Stage2State creation with default values
    fn test_stage2_state_creation() {
        let state = Stage2State::new();
        assert!(state.best_objective().is_nan());
        assert_eq!(state.solution_set_size(), 0);
        assert_eq!(state.current_iteration(), 0);
        assert!(state.best_objective().is_nan());
        assert_eq!(state.local_solver_calls(), 0);
        assert_eq!(state.improved_local_calls(), 0);
        assert_eq!(state.function_evaluations(), 0);
        assert_eq!(state.unchanged_cycles(), 0);
    }

    #[test]
    /// Test basic Stage2State updates and best objective minimization
    fn test_stage2_state_updates() {
        let mut state = Stage2State::new();

        state.set_iteration(5);
        assert_eq!(state.current_iteration(), 5);

        state.set_best_objective(1.5);
        assert_eq!(state.best_objective(), 1.5);

        // Best objective should only update if lower
        state.set_best_objective(2.0);
        assert_eq!(state.best_objective(), 1.5);

        state.set_solution_set_size(3);
        assert_eq!(state.solution_set_size(), 3);

        state.set_threshold_value(2.5);
        assert_eq!(state.threshold_value(), 2.5);

        state.add_local_solver_call(true);
        state.add_local_solver_call(false);
        assert_eq!(state.local_solver_calls(), 2);
        assert_eq!(state.improved_local_calls(), 1);

        state.add_function_evaluations(10);
        state.add_function_evaluations(50);
        assert_eq!(state.function_evaluations(), 60);

        state.set_unchanged_cycles(3);
        assert_eq!(state.unchanged_cycles(), 3);
    }
    #[test]
    /// Test timing functionality with start/end
    fn test_stage2_timing() {
        let mut state = Stage2State::new();

        state.start();
        std::thread::sleep(std::time::Duration::from_millis(10));
        state.end();

        let total_time = state.total_time();
        assert!(total_time.is_some());
        assert!(total_time.unwrap() > 0.0);
    }

    #[test]
    /// Test local solver call tracking with improved/not improved calls
    fn test_stage2_improved_local_calls() {
        let mut state = Stage2State::new();

        // No calls yet
        assert_eq!(state.improved_local_calls(), 0);

        // All improved
        state.add_local_solver_call(true);
        state.add_local_solver_call(true);
        assert_eq!(state.local_solver_calls(), 2);
        assert_eq!(state.improved_local_calls(), 2);

        // Some did not improve
        state.add_local_solver_call(false);
        state.add_local_solver_call(false);
        assert_eq!(state.local_solver_calls(), 4);
        assert_eq!(state.improved_local_calls(), 2);
    }

    #[test]
    /// Test best objective updates with edge cases (NaN, negative values, equal values)
    fn test_stage2_best_objective_edge_cases() {
        let mut state = Stage2State::new();

        // Test with NaN initial value
        assert!(state.best_objective().is_nan());

        // First valid objective should be accepted
        state.set_best_objective(10.0);
        assert_eq!(state.best_objective(), 10.0);

        // Better objective should update
        state.set_best_objective(5.0);
        assert_eq!(state.best_objective(), 5.0);

        // Worse objective should not update
        state.set_best_objective(7.0);
        assert_eq!(state.best_objective(), 5.0);

        // Equal objective should not update
        state.set_best_objective(5.0);
        assert_eq!(state.best_objective(), 5.0);

        // Negative objectives should work
        state.set_best_objective(-2.0);
        assert_eq!(state.best_objective(), -2.0);

        // Positive worse objective should not update
        state.set_best_objective(1.0);
        assert_eq!(state.best_objective(), -2.0);

        // Very small improvements
        state.set_best_objective(-2.000001);
        assert_eq!(state.best_objective(), -2.000001);
    }

    #[test]
    /// Test iteration progression and non-sequential updates
    fn test_stage2_iteration_progression() {
        let mut state = Stage2State::new();

        assert_eq!(state.current_iteration(), 0);

        for i in 1..=10 {
            state.set_iteration(i);
            assert_eq!(state.current_iteration(), i);
        }

        // Test non-sequential updates
        state.set_iteration(50);
        assert_eq!(state.current_iteration(), 50);

        state.set_iteration(25);
        assert_eq!(state.current_iteration(), 25);
    }

    #[test]
    /// Test solution set size updates with various values
    fn test_stage2_solution_set_size_variations() {
        let mut state = Stage2State::new();

        assert_eq!(state.solution_set_size(), 0);

        // Test various sizes
        let sizes = vec![1, 5, 10, 0, 100, 50];
        for &size in &sizes {
            state.set_solution_set_size(size);
            assert_eq!(state.solution_set_size(), size);
        }
    }

    #[test]
    /// Test threshold value updates including infinity
    fn test_stage2_threshold_value_updates() {
        let mut state = Stage2State::new();

        // Initial should be infinity
        assert_eq!(state.threshold_value(), f64::INFINITY);

        // Test various threshold values
        let thresholds = vec![10.0, 5.5, 1.0, 0.1, 0.0, -1.0];
        for &threshold in &thresholds {
            state.set_threshold_value(threshold);
            assert_eq!(state.threshold_value(), threshold);
        }

        // Test infinity again
        state.set_threshold_value(f64::INFINITY);
        assert_eq!(state.threshold_value(), f64::INFINITY);
    }

    #[test]
    /// Test local solver call patterns with different improvement sequences
    fn test_stage2_local_solver_call_patterns() {
        let mut state = Stage2State::new();

        // Test alternating pattern
        let pattern = vec![true, false, true, true, false, false, true];
        let mut expected_improved = 0;

        for &improved in &pattern {
            state.add_local_solver_call(improved);
            if improved {
                expected_improved += 1;
            }
        }

        assert_eq!(state.local_solver_calls(), pattern.len());
        assert_eq!(state.improved_local_calls(), expected_improved);

        // Test all false
        let mut state2 = Stage2State::new();
        for _ in 0..5 {
            state2.add_local_solver_call(false);
        }
        assert_eq!(state2.local_solver_calls(), 5);
        assert_eq!(state2.improved_local_calls(), 0);

        // Test all true
        let mut state3 = Stage2State::new();
        for _ in 0..3 {
            state3.add_local_solver_call(true);
        }
        assert_eq!(state3.local_solver_calls(), 3);
        assert_eq!(state3.improved_local_calls(), 3);
    }

    #[test]
    /// Test function evaluations accumulation with various increments
    fn test_stage2_function_evaluations_accumulation() {
        let mut state = Stage2State::new();

        assert_eq!(state.function_evaluations(), 0);

        // Test incremental additions
        state.add_function_evaluations(100);
        assert_eq!(state.function_evaluations(), 100);

        state.add_function_evaluations(250);
        assert_eq!(state.function_evaluations(), 350);

        state.add_function_evaluations(0);
        assert_eq!(state.function_evaluations(), 350);

        // Large numbers
        state.add_function_evaluations(10000);
        assert_eq!(state.function_evaluations(), 10350);
    }

    #[test]
    /// Test unchanged cycles tracking for convergence detection
    fn test_stage2_unchanged_cycles_tracking() {
        let mut state = Stage2State::new();

        assert_eq!(state.unchanged_cycles(), 0);

        // Test increasing cycles
        for i in 1..=5 {
            state.set_unchanged_cycles(i);
            assert_eq!(state.unchanged_cycles(), i);
        }

        // Test reset to zero
        state.set_unchanged_cycles(0);
        assert_eq!(state.unchanged_cycles(), 0);

        // Test large numbers
        state.set_unchanged_cycles(1000);
        assert_eq!(state.unchanged_cycles(), 1000);
    }

    #[test]
    /// Test timing edge cases (no start, ongoing timing, end timing)
    fn test_stage2_timing_edge_cases() {
        let mut state = Stage2State::new();

        // No timing started
        assert!(state.total_time().is_none());

        // End without start
        state.end();
        assert!(state.total_time().is_none());

        // Start and check ongoing time
        state.start();
        let time1 = state.total_time().unwrap();
        assert!(time1 >= 0.0);

        std::thread::sleep(std::time::Duration::from_millis(5));

        let time2 = state.total_time().unwrap();
        assert!(time2 > time1);

        // End and check final time
        state.end();
        let final_time = state.total_time().unwrap();
        assert!(final_time >= time2);
    }

    #[test]
    /// Test that clone preserves all state correctly
    fn test_stage2_clone_behavior() {
        let mut state = Stage2State::new();
        state.set_iteration(10);
        state.set_best_objective(3.5);
        state.set_solution_set_size(8);
        state.set_threshold_value(2.0);
        state.add_local_solver_call(true);
        state.add_local_solver_call(false);
        state.add_function_evaluations(500);
        state.set_unchanged_cycles(2);

        let cloned = state.clone();

        assert_eq!(cloned.current_iteration(), 10);
        assert_eq!(cloned.best_objective(), 3.5);
        assert_eq!(cloned.solution_set_size(), 8);
        assert_eq!(cloned.threshold_value(), 2.0);
        assert_eq!(cloned.local_solver_calls(), 2);
        assert_eq!(cloned.improved_local_calls(), 1);
        assert_eq!(cloned.function_evaluations(), 500);
        assert_eq!(cloned.unchanged_cycles(), 2);
    }

    #[test]
    /// Test Default implementation creates same state as new()
    fn test_stage2_default_implementation() {
        let state = Stage2State::default();
        assert!(state.best_objective().is_nan());
        assert_eq!(state.solution_set_size(), 0);
        assert_eq!(state.current_iteration(), 0);
        assert_eq!(state.threshold_value(), f64::INFINITY);
        assert_eq!(state.local_solver_calls(), 0);
        assert_eq!(state.improved_local_calls(), 0);
        assert_eq!(state.function_evaluations(), 0);
        assert_eq!(state.unchanged_cycles(), 0);
    }

    #[test]
    /// Test convergence scenario with improving then stagnant iterations
    fn test_stage2_convergence_scenario() {
        let mut state = Stage2State::new();

        // Simulate a convergence scenario
        state.set_best_objective(10.0);
        state.set_solution_set_size(5);
        state.set_threshold_value(8.0);

        // Early iterations with improvements
        for iter in 1..=5 {
            state.set_iteration(iter);
            state.add_local_solver_call(true);
            state.add_function_evaluations(20);
        }

        assert_eq!(state.current_iteration(), 5);
        assert_eq!(state.local_solver_calls(), 5);
        assert_eq!(state.improved_local_calls(), 5);
        assert_eq!(state.function_evaluations(), 100);

        // Later iterations with no improvements (convergence)
        for iter in 6..=10 {
            state.set_iteration(iter);
            state.add_local_solver_call(false);
            state.add_function_evaluations(15);
            state.set_unchanged_cycles(iter - 5); // Increasing unchanged cycles
        }

        assert_eq!(state.current_iteration(), 10);
        assert_eq!(state.local_solver_calls(), 10);
        assert_eq!(state.improved_local_calls(), 5); // Still 5 improved
        assert_eq!(state.function_evaluations(), 100 + 5 * 15); // 175
        assert_eq!(state.unchanged_cycles(), 5);
    }

    #[test]
    /// Test best_point tracking with set_best_solution
    fn test_stage2_best_point_tracking() {
        use ndarray::array;

        let mut state = Stage2State::new();

        // Initially no best point
        assert!(state.best_point().is_none());
        assert!(state.best_objective().is_nan());

        // Set first solution
        state.set_best_solution(10.0, &array![1.0, 2.0]);
        assert!(state.best_point().is_some());
        assert_eq!(state.best_objective(), 10.0);
        assert_eq!(state.best_point().unwrap(), &array![1.0, 2.0]);

        // Better solution should update
        state.set_best_solution(5.0, &array![3.0, 4.0]);
        assert_eq!(state.best_objective(), 5.0);
        assert_eq!(state.best_point().unwrap(), &array![3.0, 4.0]);

        // Worse solution should not update
        state.set_best_solution(8.0, &array![5.0, 6.0]);
        assert_eq!(state.best_objective(), 5.0);
        assert_eq!(state.best_point().unwrap(), &array![3.0, 4.0]);
    }

    #[test]
    /// Test set_best_objective doesn't change best_point
    fn test_stage2_best_objective_independent() {
        use ndarray::array;

        let mut state = Stage2State::new();

        // Set solution with coordinates
        state.set_best_solution(10.0, &array![1.0, 2.0]);
        assert_eq!(state.best_objective(), 10.0);
        assert_eq!(state.best_point().unwrap(), &array![1.0, 2.0]);

        // Using set_best_objective should update objective but not point
        state.set_best_objective(5.0);
        assert_eq!(state.best_objective(), 5.0);
        assert_eq!(state.best_point().unwrap(), &array![1.0, 2.0]); // Point unchanged
    }
}