1use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum ConvergenceStatus {
11 NotConverged,
13 Converged(ConvergenceReason),
15}
16
17impl ConvergenceStatus {
18 pub fn is_converged(&self) -> bool {
20 matches!(self, Self::Converged(_))
21 }
22}
23
24#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ConvergenceReason {
27 FitnessStagnation { generations: usize },
29 LowDiversity { diversity: u64 }, TargetReached { target: u64 }, MaxGenerations { generations: usize },
35 MaxEvaluations { evaluations: usize },
37 RhatConverged { rhat: u64 }, MultipleReasons(Vec<ConvergenceReason>),
41 Custom(String),
43}
44
45impl ConvergenceReason {
46 pub fn fitness_stagnation(generations: usize) -> Self {
48 Self::FitnessStagnation { generations }
49 }
50
51 pub fn low_diversity(diversity: f64) -> Self {
53 Self::LowDiversity {
54 diversity: diversity.to_bits(),
55 }
56 }
57
58 pub fn target_reached(target: f64) -> Self {
60 Self::TargetReached {
61 target: target.to_bits(),
62 }
63 }
64
65 pub fn rhat_converged(rhat: f64) -> Self {
67 Self::RhatConverged {
68 rhat: rhat.to_bits(),
69 }
70 }
71}
72
73#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct ConvergenceConfig {
76 pub max_generations: Option<usize>,
78 pub max_evaluations: Option<usize>,
80 pub target_fitness: Option<f64>,
82 pub target_tolerance: f64,
84 pub stagnation_generations: usize,
86 pub stagnation_threshold: f64,
88 pub diversity_threshold: f64,
90 pub rhat_threshold: f64,
92 pub use_rhat: bool,
94}
95
96impl Default for ConvergenceConfig {
97 fn default() -> Self {
98 Self {
99 max_generations: None,
100 max_evaluations: None,
101 target_fitness: None,
102 target_tolerance: 1e-6,
103 stagnation_generations: 50,
104 stagnation_threshold: 1e-9,
105 diversity_threshold: 0.01,
106 rhat_threshold: 1.1,
107 use_rhat: false,
108 }
109 }
110}
111
112impl ConvergenceConfig {
113 pub fn with_max_generations(generations: usize) -> Self {
115 Self {
116 max_generations: Some(generations),
117 ..Default::default()
118 }
119 }
120
121 pub fn max_generations(mut self, generations: usize) -> Self {
123 self.max_generations = Some(generations);
124 self
125 }
126
127 pub fn max_evaluations(mut self, evaluations: usize) -> Self {
129 self.max_evaluations = Some(evaluations);
130 self
131 }
132
133 pub fn target_fitness(mut self, target: f64) -> Self {
135 self.target_fitness = Some(target);
136 self
137 }
138
139 pub fn target_tolerance(mut self, tolerance: f64) -> Self {
141 self.target_tolerance = tolerance;
142 self
143 }
144
145 pub fn stagnation(mut self, generations: usize, threshold: f64) -> Self {
147 self.stagnation_generations = generations;
148 self.stagnation_threshold = threshold;
149 self
150 }
151
152 pub fn diversity_threshold(mut self, threshold: f64) -> Self {
154 self.diversity_threshold = threshold;
155 self
156 }
157
158 pub fn with_rhat(mut self, threshold: f64) -> Self {
160 self.use_rhat = true;
161 self.rhat_threshold = threshold;
162 self
163 }
164}
165
166#[derive(Clone, Debug)]
168pub struct ConvergenceDetector {
169 config: ConvergenceConfig,
171 best_fitness_history: Vec<f64>,
173 mean_fitness_history: Vec<f64>,
179 diversity_history: Vec<f64>,
181 current_generation: usize,
183 current_evaluations: usize,
185 best_fitness_overall: f64,
190 running_best_fitness: f64,
196 last_improvement_generation: usize,
198}
199
200fn two_pass_mean_var(xs: &[f64]) -> (f64, f64) {
211 let n = xs.len() as f64;
212 let mean = xs.iter().sum::<f64>() / n;
213 if xs.len() < 2 {
214 return (mean, 0.0);
215 }
216 let ss: f64 = xs.iter().map(|x| (x - mean).powi(2)).sum();
217 (mean, ss / (n - 1.0))
218}
219
220impl ConvergenceDetector {
221 pub fn new(config: ConvergenceConfig) -> Self {
223 Self {
224 config,
225 best_fitness_history: Vec::new(),
226 mean_fitness_history: Vec::new(),
227 diversity_history: Vec::new(),
228 current_generation: 0,
229 current_evaluations: 0,
230 best_fitness_overall: f64::NEG_INFINITY,
231 running_best_fitness: f64::NEG_INFINITY,
232 last_improvement_generation: 0,
233 }
234 }
235
236 pub fn with_defaults() -> Self {
238 Self::new(ConvergenceConfig::default())
239 }
240
241 pub fn update(
243 &mut self,
244 generation: usize,
245 evaluations: usize,
246 best_fitness: f64,
247 mean_fitness: f64,
248 diversity: f64,
249 ) {
250 self.current_generation = generation;
251 self.current_evaluations = evaluations;
252 self.best_fitness_history.push(best_fitness);
253 self.mean_fitness_history.push(mean_fitness);
254 self.diversity_history.push(diversity);
255
256 if best_fitness > self.running_best_fitness {
259 self.running_best_fitness = best_fitness;
260 }
261
262 if best_fitness > self.best_fitness_overall + self.config.stagnation_threshold {
266 self.best_fitness_overall = best_fitness;
267 self.last_improvement_generation = generation;
268 }
269 }
270
271 pub fn check(&self) -> ConvergenceStatus {
273 let mut reasons = Vec::new();
274
275 if let Some(max_gen) = self.config.max_generations {
277 if self.current_generation >= max_gen {
278 reasons.push(ConvergenceReason::MaxGenerations {
279 generations: self.current_generation,
280 });
281 }
282 }
283
284 if let Some(max_eval) = self.config.max_evaluations {
286 if self.current_evaluations >= max_eval {
287 reasons.push(ConvergenceReason::MaxEvaluations {
288 evaluations: self.current_evaluations,
289 });
290 }
291 }
292
293 if let Some(target) = self.config.target_fitness {
304 if !self.best_fitness_history.is_empty() {
305 let best = self.running_best_fitness;
306 if (best - target).abs() <= self.config.target_tolerance || best >= target {
307 reasons.push(ConvergenceReason::target_reached(best));
308 }
309 }
310 }
311
312 let generations_since_improvement =
314 self.current_generation - self.last_improvement_generation;
315 if generations_since_improvement >= self.config.stagnation_generations {
316 reasons.push(ConvergenceReason::fitness_stagnation(
317 generations_since_improvement,
318 ));
319 }
320
321 if let Some(&diversity) = self.diversity_history.last() {
323 if diversity < self.config.diversity_threshold {
324 reasons.push(ConvergenceReason::low_diversity(diversity));
325 }
326 }
327
328 if self.config.use_rhat && self.mean_fitness_history.len() >= 10 {
330 let rhat = self.compute_rhat();
332 if rhat < self.config.rhat_threshold {
333 reasons.push(ConvergenceReason::rhat_converged(rhat));
334 }
335 }
336
337 match reasons.len() {
339 0 => ConvergenceStatus::NotConverged,
340 1 => ConvergenceStatus::Converged(reasons.pop().unwrap()),
341 _ => ConvergenceStatus::Converged(ConvergenceReason::MultipleReasons(reasons)),
342 }
343 }
344
345 fn compute_rhat(&self) -> f64 {
366 let n = self.mean_fitness_history.len();
367 let l = n / 2;
368
369 if l < 5 {
370 return f64::INFINITY; }
372
373 let l_f = l as f64;
374
375 let (mean1, var1) = two_pass_mean_var(&self.mean_fitness_history[0..l]);
378 let (mean2, var2) = two_pass_mean_var(&self.mean_fitness_history[l..2 * l]);
379
380 let m = 2.0;
381 let grand_mean = (mean1 + mean2) / m;
382 let b = l_f / (m - 1.0) * ((mean1 - grand_mean).powi(2) + (mean2 - grand_mean).powi(2));
383 let w = (var1 + var2) / m;
384
385 if w <= 0.0 {
386 return 1.0; }
388
389 let var_plus = ((l_f - 1.0) / l_f) * w + b / l_f;
390 (var_plus / w).sqrt()
391 }
392
393 pub fn best_fitness(&self) -> f64 {
396 self.running_best_fitness
397 }
398
399 pub fn generations_without_improvement(&self) -> usize {
401 self.current_generation - self.last_improvement_generation
402 }
403
404 pub fn current_diversity(&self) -> Option<f64> {
406 self.diversity_history.last().copied()
407 }
408
409 pub fn fitness_history(&self) -> &[f64] {
411 &self.best_fitness_history
412 }
413
414 pub fn diversity_history(&self) -> &[f64] {
416 &self.diversity_history
417 }
418
419 pub fn reset(&mut self) {
421 self.best_fitness_history.clear();
422 self.mean_fitness_history.clear();
423 self.diversity_history.clear();
424 self.current_generation = 0;
425 self.current_evaluations = 0;
426 self.best_fitness_overall = f64::NEG_INFINITY;
427 self.running_best_fitness = f64::NEG_INFINITY;
428 self.last_improvement_generation = 0;
429 }
430}
431
432pub fn evolutionary_rhat(runs: &[Vec<f64>]) -> f64 {
437 if runs.is_empty() || runs[0].is_empty() {
438 return f64::INFINITY;
439 }
440
441 let m = runs.len() as f64;
442 let n_len = runs.iter().map(|r| r.len()).min().unwrap_or(0);
449 let n = n_len as f64;
450
451 if n < 2.0 || m < 2.0 {
452 return f64::INFINITY;
453 }
454
455 let chain_means: Vec<f64> = runs
457 .iter()
458 .map(|r| r[..n_len].iter().sum::<f64>() / n)
459 .collect();
460 let grand_mean = chain_means.iter().sum::<f64>() / m;
461 let b = n / (m - 1.0)
462 * chain_means
463 .iter()
464 .map(|cm| (cm - grand_mean).powi(2))
465 .sum::<f64>();
466
467 let w: f64 = runs
469 .iter()
470 .map(|r| {
471 let chain = &r[..n_len];
472 let mean = chain.iter().sum::<f64>() / n;
473 chain.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0)
474 })
475 .sum::<f64>()
476 / m;
477
478 if w == 0.0 {
479 return 1.0; }
481
482 let var_plus = ((n - 1.0) / n) * w + b / n;
484
485 (var_plus / w).sqrt()
486}
487
488pub fn evolutionary_ess(weights: &[f64]) -> f64 {
492 if weights.is_empty() {
493 return 0.0;
494 }
495
496 let sum: f64 = weights.iter().sum();
498 if sum == 0.0 {
499 return weights.len() as f64;
500 }
501
502 let normalized: Vec<f64> = weights.iter().map(|w| w / sum).collect();
503 let sum_sq: f64 = normalized.iter().map(|w| w * w).sum();
504
505 if sum_sq == 0.0 {
506 weights.len() as f64
507 } else {
508 1.0 / sum_sq
509 }
510}
511
512pub fn evolutionary_ess_log(log_weights: &[f64]) -> f64 {
514 if log_weights.is_empty() {
515 return 0.0;
516 }
517
518 let max_log = log_weights
520 .iter()
521 .cloned()
522 .fold(f64::NEG_INFINITY, f64::max);
523
524 if max_log.is_infinite() {
525 return log_weights.len() as f64;
526 }
527
528 let weights: Vec<f64> = log_weights.iter().map(|lw| (lw - max_log).exp()).collect();
529 evolutionary_ess(&weights)
530}
531
532pub fn detect_stagnation(fitness_history: &[f64], threshold: f64) -> usize {
536 if fitness_history.len() < 2 {
537 return 0;
538 }
539
540 let best = fitness_history
541 .iter()
542 .cloned()
543 .fold(f64::NEG_INFINITY, f64::max);
544
545 let mut stagnant_count: usize = 0;
547 for &fitness in fitness_history.iter().rev() {
548 if (fitness - best).abs() <= threshold {
549 stagnant_count += 1;
550 } else {
551 break;
552 }
553 }
554
555 stagnant_count.saturating_sub(1) }
557
558pub fn fitness_convergence(fitness_values: &[f64]) -> f64 {
563 if fitness_values.len() < 2 {
564 return 1.0;
565 }
566
567 let mean = fitness_values.iter().sum::<f64>() / fitness_values.len() as f64;
568 if mean.abs() < f64::EPSILON {
569 return 1.0;
570 }
571
572 let variance = fitness_values
573 .iter()
574 .map(|f| (f - mean).powi(2))
575 .sum::<f64>()
576 / (fitness_values.len() - 1) as f64;
577 let std = variance.sqrt();
578
579 let cv = std / mean.abs();
581
582 (-cv).exp()
586}
587
588#[derive(Clone, Debug)]
590pub struct TerminationCriteria {
591 criteria: Vec<TerminationCriterion>,
592 require_all: bool,
593}
594
595#[derive(Clone, Debug)]
597pub enum TerminationCriterion {
598 MaxGenerations(usize),
600 MaxEvaluations(usize),
602 TargetFitness(f64, f64), Stagnation(usize, f64), DiversityThreshold(f64),
608 TimeLimit(f64),
610 Custom(String), }
613
614impl TerminationCriteria {
615 pub fn new() -> Self {
617 Self {
618 criteria: Vec::new(),
619 require_all: false,
620 }
621 }
622
623 pub fn require_all() -> Self {
625 Self {
626 criteria: Vec::new(),
627 require_all: true,
628 }
629 }
630
631 pub fn add(mut self, criterion: TerminationCriterion) -> Self {
633 self.criteria.push(criterion);
634 self
635 }
636
637 pub fn max_generations(self, generations: usize) -> Self {
639 self.add(TerminationCriterion::MaxGenerations(generations))
640 }
641
642 pub fn max_evaluations(self, evaluations: usize) -> Self {
644 self.add(TerminationCriterion::MaxEvaluations(evaluations))
645 }
646
647 pub fn target_fitness(self, target: f64, tolerance: f64) -> Self {
649 self.add(TerminationCriterion::TargetFitness(target, tolerance))
650 }
651
652 pub fn stagnation(self, generations: usize, threshold: f64) -> Self {
654 self.add(TerminationCriterion::Stagnation(generations, threshold))
655 }
656
657 pub fn diversity_threshold(self, threshold: f64) -> Self {
659 self.add(TerminationCriterion::DiversityThreshold(threshold))
660 }
661
662 pub fn time_limit(self, seconds: f64) -> Self {
664 self.add(TerminationCriterion::TimeLimit(seconds))
665 }
666
667 pub fn should_terminate(
675 &self,
676 generation: usize,
677 evaluations: usize,
678 best_fitness: f64,
679 diversity: f64,
680 fitness_history: &[f64],
681 elapsed_seconds: f64,
682 ) -> Option<ConvergenceReason> {
683 let mut satisfied = Vec::new();
684
685 for criterion in &self.criteria {
686 let met = match criterion {
687 TerminationCriterion::MaxGenerations(max) => generation >= *max,
688 TerminationCriterion::MaxEvaluations(max) => evaluations >= *max,
689 TerminationCriterion::TargetFitness(target, tolerance) => {
690 (best_fitness - target).abs() <= *tolerance || best_fitness >= *target
691 }
692 TerminationCriterion::Stagnation(gens, threshold) => {
693 detect_stagnation(fitness_history, *threshold) >= *gens
694 }
695 TerminationCriterion::DiversityThreshold(thresh) => diversity < *thresh,
696 TerminationCriterion::TimeLimit(limit) => elapsed_seconds >= *limit,
697 TerminationCriterion::Custom(_) => false, };
699
700 if met {
701 satisfied.push(criterion.to_reason(
702 generation,
703 evaluations,
704 best_fitness,
705 diversity,
706 ));
707 }
708 }
709
710 if satisfied.is_empty() {
711 return None;
712 }
713
714 if self.require_all && satisfied.len() < self.criteria.len() {
715 return None;
716 }
717
718 if satisfied.len() == 1 {
720 Some(satisfied.pop().unwrap())
721 } else {
722 Some(ConvergenceReason::MultipleReasons(satisfied))
723 }
724 }
725
726 pub fn criteria(&self) -> &[TerminationCriterion] {
728 &self.criteria
729 }
730}
731
732impl Default for TerminationCriteria {
733 fn default() -> Self {
734 Self::new()
735 }
736}
737
738impl TerminationCriterion {
739 fn to_reason(
740 &self,
741 generation: usize,
742 evaluations: usize,
743 best_fitness: f64,
744 diversity: f64,
745 ) -> ConvergenceReason {
746 match self {
747 Self::MaxGenerations(_) => ConvergenceReason::MaxGenerations {
748 generations: generation,
749 },
750 Self::MaxEvaluations(_) => ConvergenceReason::MaxEvaluations { evaluations },
751 Self::TargetFitness(_, _) => ConvergenceReason::target_reached(best_fitness),
752 Self::Stagnation(gens, _) => ConvergenceReason::fitness_stagnation(*gens),
753 Self::DiversityThreshold(_) => ConvergenceReason::low_diversity(diversity),
754 Self::TimeLimit(t) => ConvergenceReason::Custom(format!("Time limit of {t}s reached")),
755 Self::Custom(desc) => ConvergenceReason::Custom(desc.clone()),
756 }
757 }
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763
764 #[test]
765 fn test_convergence_detector_basic() {
766 let config = ConvergenceConfig::with_max_generations(100);
767 let mut detector = ConvergenceDetector::new(config);
768
769 for i in 0..50 {
771 detector.update(i, i * 10, i as f64, i as f64 * 0.5, 0.5);
772 }
773
774 let status = detector.check();
775 assert!(!status.is_converged());
776 }
777
778 #[test]
779 fn test_convergence_detector_max_generations() {
780 let config = ConvergenceConfig::with_max_generations(50);
781 let mut detector = ConvergenceDetector::new(config);
782
783 for i in 0..60 {
784 detector.update(i, i * 10, i as f64, i as f64 * 0.5, 0.5);
785 }
786
787 let status = detector.check();
788 assert!(status.is_converged());
789 if let ConvergenceStatus::Converged(reason) = status {
790 assert!(matches!(reason, ConvergenceReason::MaxGenerations { .. }));
791 }
792 }
793
794 #[test]
795 fn test_convergence_detector_target_fitness() {
796 let config = ConvergenceConfig::default()
797 .target_fitness(100.0)
798 .target_tolerance(1.0);
799 let mut detector = ConvergenceDetector::new(config);
800
801 detector.update(0, 10, 99.5, 50.0, 0.5);
802
803 let status = detector.check();
804 assert!(status.is_converged());
805 }
806
807 #[test]
808 fn test_convergence_detector_stagnation() {
809 let config = ConvergenceConfig::default().stagnation(10, 1e-9);
810 let mut detector = ConvergenceDetector::new(config);
811
812 detector.update(0, 10, 50.0, 50.0, 0.5);
814
815 for i in 1..20 {
817 detector.update(i, i * 10, 50.0, 50.0, 0.5);
818 }
819
820 let status = detector.check();
821 assert!(status.is_converged());
822 if let ConvergenceStatus::Converged(reason) = status {
823 assert!(matches!(
824 reason,
825 ConvergenceReason::FitnessStagnation { .. }
826 ));
827 }
828 }
829
830 #[test]
831 fn test_convergence_detector_low_diversity() {
832 let config = ConvergenceConfig::default().diversity_threshold(0.1);
833 let mut detector = ConvergenceDetector::new(config);
834
835 detector.update(0, 10, 50.0, 50.0, 0.05);
836
837 let status = detector.check();
838 assert!(status.is_converged());
839 if let ConvergenceStatus::Converged(reason) = status {
840 assert!(matches!(reason, ConvergenceReason::LowDiversity { .. }));
841 }
842 }
843
844 #[test]
845 fn test_evolutionary_rhat() {
846 let chain1 = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
848 let chain2 = vec![1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5, 10.5];
849 let rhat = evolutionary_rhat(&[chain1, chain2]);
850 assert!(rhat < 1.2, "R-hat was {}, expected < 1.2", rhat);
852 }
853
854 #[test]
855 fn test_evolutionary_rhat_divergent() {
856 let chain1 = vec![1.0, 2.0, 1.5, 2.5, 1.2, 2.8, 1.8, 2.2, 1.3, 2.7];
858 let chain2 = vec![
859 100.0, 101.0, 100.5, 101.5, 100.2, 101.8, 100.8, 101.2, 100.3, 101.7,
860 ];
861 let rhat = evolutionary_rhat(&[chain1, chain2]);
862 assert!(rhat > 1.5, "R-hat was {}, expected > 1.5", rhat);
864 }
865
866 #[test]
867 fn test_evolutionary_ess() {
868 let weights = vec![1.0, 1.0, 1.0, 1.0];
870 let ess = evolutionary_ess(&weights);
871 assert!((ess - 4.0).abs() < 0.01);
872 }
873
874 #[test]
875 fn test_evolutionary_ess_unequal() {
876 let weights = vec![1.0, 0.0, 0.0, 0.0];
878 let ess = evolutionary_ess(&weights);
879 assert!((ess - 1.0).abs() < 0.01);
880 }
881
882 #[test]
883 fn test_evolutionary_ess_log() {
884 let log_weights = vec![0.0, 0.0, 0.0, 0.0];
885 let ess = evolutionary_ess_log(&log_weights);
886 assert!((ess - 4.0).abs() < 0.01);
887 }
888
889 #[test]
890 fn test_detect_stagnation() {
891 let history = vec![10.0, 20.0, 30.0, 30.0, 30.0, 30.0];
892 let stagnant = detect_stagnation(&history, 1e-9);
893 assert_eq!(stagnant, 3); }
895
896 #[test]
897 fn test_detect_stagnation_improving() {
898 let history = vec![10.0, 20.0, 30.0, 40.0, 50.0];
899 let stagnant = detect_stagnation(&history, 1e-9);
900 assert_eq!(stagnant, 0);
901 }
902
903 #[test]
904 fn test_fitness_convergence() {
905 let fitness = vec![50.0, 50.0, 50.0, 50.0];
907 let conv = fitness_convergence(&fitness);
908 assert!((conv - 1.0).abs() < 0.01);
909 }
910
911 #[test]
912 fn test_fitness_convergence_diverse() {
913 let fitness = vec![0.0, 100.0, 0.0, 100.0];
915 let conv = fitness_convergence(&fitness);
916 assert!(conv < 0.5);
917 }
918
919 #[test]
920 fn test_termination_criteria_max_gen() {
921 let criteria = TerminationCriteria::new().max_generations(100);
922
923 let result = criteria.should_terminate(50, 500, 10.0, 0.5, &[], 10.0);
924 assert!(result.is_none());
925
926 let result = criteria.should_terminate(100, 1000, 10.0, 0.5, &[], 20.0);
927 assert!(result.is_some());
928 }
929
930 #[test]
931 fn test_termination_criteria_target() {
932 let criteria = TerminationCriteria::new().target_fitness(100.0, 1.0);
933
934 let result = criteria.should_terminate(10, 100, 50.0, 0.5, &[], 5.0);
935 assert!(result.is_none());
936
937 let result = criteria.should_terminate(10, 100, 99.5, 0.5, &[], 5.0);
938 assert!(result.is_some());
939 }
940
941 #[test]
942 fn test_termination_criteria_multiple() {
943 let criteria = TerminationCriteria::new()
944 .max_generations(100)
945 .target_fitness(100.0, 1.0);
946
947 let result = criteria.should_terminate(10, 100, 50.0, 0.5, &[], 5.0);
949 assert!(result.is_none());
950
951 let result = criteria.should_terminate(10, 100, 100.0, 0.5, &[], 5.0);
953 assert!(result.is_some());
954
955 let result = criteria.should_terminate(100, 1000, 50.0, 0.5, &[], 50.0);
957 assert!(result.is_some());
958 }
959
960 #[test]
961 fn test_termination_criteria_require_all() {
962 let criteria = TerminationCriteria::require_all()
963 .max_generations(100)
964 .stagnation(10, 1e-9);
965
966 let short_flat = [50.0; 6];
969 let result = criteria.should_terminate(100, 1000, 50.0, 0.5, &short_flat, 50.0);
970 assert!(result.is_none());
971
972 let long_flat = [50.0; 11];
974 let result = criteria.should_terminate(100, 1000, 50.0, 0.5, &long_flat, 50.0);
975 assert!(result.is_some());
976 }
977
978 #[test]
979 fn test_convergence_config_builder() {
980 let config = ConvergenceConfig::with_max_generations(500)
981 .max_evaluations(10000)
982 .target_fitness(1.0)
983 .target_tolerance(0.01)
984 .stagnation(100, 1e-6)
985 .diversity_threshold(0.05)
986 .with_rhat(1.05);
987
988 assert_eq!(config.max_generations, Some(500));
989 assert_eq!(config.max_evaluations, Some(10000));
990 assert_eq!(config.target_fitness, Some(1.0));
991 assert_eq!(config.target_tolerance, 0.01);
992 assert_eq!(config.stagnation_generations, 100);
993 assert_eq!(config.stagnation_threshold, 1e-6);
994 assert_eq!(config.diversity_threshold, 0.05);
995 assert!(config.use_rhat);
996 assert_eq!(config.rhat_threshold, 1.05);
997 }
998
999 #[test]
1000 fn test_convergence_detector_reset() {
1001 let config = ConvergenceConfig::default();
1002 let mut detector = ConvergenceDetector::new(config);
1003
1004 detector.update(0, 10, 50.0, 50.0, 0.5);
1005 detector.update(1, 20, 60.0, 55.0, 0.4);
1006
1007 assert_eq!(detector.fitness_history().len(), 2);
1008 assert_eq!(detector.best_fitness(), 60.0);
1009
1010 detector.reset();
1011
1012 assert!(detector.fitness_history().is_empty());
1013 assert_eq!(detector.best_fitness(), f64::NEG_INFINITY);
1014 }
1015
1016 #[test]
1017 fn test_convergence_status_is_converged() {
1018 let not_converged = ConvergenceStatus::NotConverged;
1019 assert!(!not_converged.is_converged());
1020
1021 let converged =
1022 ConvergenceStatus::Converged(ConvergenceReason::MaxGenerations { generations: 100 });
1023 assert!(converged.is_converged());
1024 }
1025
1026 #[test]
1032 fn test_evolutionary_rhat_truncates_unequal_chains() {
1033 let chain1 = vec![1.0, 2.0, 3.0, 4.0];
1034 let chain2 = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1035 let rhat = evolutionary_rhat(&[chain1, chain2]);
1036 let expected = 0.75_f64.sqrt();
1037 assert!(
1038 (rhat - expected).abs() < 1e-9,
1039 "R-hat was {rhat}, expected {expected}"
1040 );
1041 assert!(
1042 rhat < 0.9,
1043 "R-hat {rhat} still shows the unequal-length bug"
1044 );
1045 }
1046
1047 #[test]
1052 fn test_target_fitness_uses_running_best() {
1053 let config = ConvergenceConfig::default()
1054 .target_fitness(100.0)
1055 .target_tolerance(1e-6)
1056 .stagnation(10_000, 1e-9); let mut detector = ConvergenceDetector::new(config);
1058
1059 detector.update(0, 10, 100.0, 50.0, 1.0); detector.update(1, 20, 50.0, 50.0, 1.0); assert_eq!(detector.best_fitness(), 100.0);
1063 let status = detector.check();
1064 assert!(
1065 status.is_converged(),
1066 "a target reached earlier must remain converged"
1067 );
1068 if let ConvergenceStatus::Converged(reason) = status {
1069 assert!(matches!(reason, ConvergenceReason::TargetReached { .. }));
1070 }
1071 }
1072
1073 #[test]
1078 fn test_target_fitness_survives_stagnation_throttle() {
1079 let config = ConvergenceConfig::default()
1080 .target_fitness(100.0)
1081 .target_tolerance(1e-6)
1082 .stagnation(50, 0.01);
1084 let mut detector = ConvergenceDetector::new(config);
1085
1086 detector.update(0, 10, 99.995, 50.0, 1.0);
1088 detector.update(1, 20, 100.0, 50.0, 1.0);
1091
1092 assert_eq!(
1093 detector.best_fitness(),
1094 100.0,
1095 "the running best must reflect the true max, not the throttled value"
1096 );
1097
1098 let status = detector.check();
1099 assert!(
1100 status.is_converged(),
1101 "target reached-and-held must be reported even when \
1102 stagnation_threshold > target_tolerance"
1103 );
1104 assert!(
1105 matches!(
1106 status,
1107 ConvergenceStatus::Converged(ConvergenceReason::TargetReached { .. })
1108 | ConvergenceStatus::Converged(ConvergenceReason::MultipleReasons(_))
1109 ),
1110 "convergence reason must include TargetReached, got {status:?}"
1111 );
1112
1113 for g in 2..10 {
1117 detector.update(g, 10 * (g + 1), 100.0, 50.0, 1.0);
1118 let s = detector.check();
1119 let has_target = match &s {
1120 ConvergenceStatus::Converged(ConvergenceReason::TargetReached { .. }) => true,
1121 ConvergenceStatus::Converged(ConvergenceReason::MultipleReasons(rs)) => rs
1122 .iter()
1123 .any(|r| matches!(r, ConvergenceReason::TargetReached { .. })),
1124 _ => false,
1125 };
1126 assert!(
1127 has_target,
1128 "target must stay reported while held, gen {g}: {s:?}"
1129 );
1130 }
1131 }
1132
1133 #[test]
1138 fn test_stagnation_threshold_is_wired() {
1139 let history = [10.0, 9.5, 9.5, 9.5, 9.5];
1140
1141 let loose = TerminationCriteria::new().stagnation(3, 1.0);
1142 let tight = TerminationCriteria::new().stagnation(3, 0.1);
1143
1144 assert!(
1145 loose
1146 .should_terminate(0, 0, 9.5, 1.0, &history, 0.0)
1147 .is_some(),
1148 "loose threshold should read the flat tail as stagnant"
1149 );
1150 assert!(
1151 tight
1152 .should_terminate(0, 0, 9.5, 1.0, &history, 0.0)
1153 .is_none(),
1154 "tight threshold should not read a 0.5 drop as stagnant"
1155 );
1156 }
1157
1158 #[test]
1163 fn test_compute_rhat_matches_naive_recompute() {
1164 let mut detector = ConvergenceDetector::with_defaults();
1165 let values: Vec<f64> = (0..40)
1166 .map(|i| {
1167 let x = i as f64;
1168 (x * 0.37).sin() * 3.0 + (x * 0.11).cos() * 1.5 + x * 0.05
1169 })
1170 .collect();
1171
1172 for (i, &v) in values.iter().enumerate() {
1173 detector.update(i, i * 10, v, v, 0.5);
1174 if detector.mean_fitness_history.len() >= 10 {
1175 let n = detector.mean_fitness_history.len();
1176 let half = n / 2;
1177 let chain1 = detector.mean_fitness_history[..half].to_vec();
1178 let chain2 = detector.mean_fitness_history[half..].to_vec();
1179 let naive = evolutionary_rhat(&[chain1, chain2]);
1180 let incremental = detector.compute_rhat();
1181 assert!(
1182 (naive - incremental).abs() < 1e-9,
1183 "at n={n}: incremental R-hat {incremental} != naive {naive}"
1184 );
1185 }
1186 }
1187 }
1188
1189 #[test]
1199 fn test_compute_rhat_stable_under_large_offset() {
1200 let offset = 1e6;
1204 let chain1: Vec<f64> = (0..20)
1205 .map(|i| offset + (i as f64 * 0.7).sin() * 1.3)
1206 .collect();
1207 let chain2: Vec<f64> = (0..20)
1208 .map(|i| offset + 4.0 + (i as f64 * 0.9 + 0.5).cos() * 1.1)
1209 .collect();
1210
1211 let mut detector = ConvergenceDetector::with_defaults();
1214 for (i, &v) in chain1.iter().chain(chain2.iter()).enumerate() {
1215 detector.update(i, i, v, v, 0.5);
1216 }
1217 let got = detector.compute_rhat();
1218
1219 let tp = |xs: &[f64]| -> (f64, f64) {
1222 let n = xs.len() as f64;
1223 let mean = xs.iter().sum::<f64>() / n;
1224 let ss: f64 = xs.iter().map(|x| (x - mean).powi(2)).sum();
1225 (mean, ss / (n - 1.0))
1226 };
1227 let (mean1, var1) = tp(&chain1);
1228 let (mean2, var2) = tp(&chain2);
1229 let l = chain1.len() as f64;
1230 let m = 2.0;
1231 let grand = (mean1 + mean2) / m;
1232 let b = l / (m - 1.0) * ((mean1 - grand).powi(2) + (mean2 - grand).powi(2));
1233 let w = (var1 + var2) / m;
1234 assert!(w > 0.0, "true within-chain variance must be positive");
1235 let var_plus = ((l - 1.0) / l) * w + b / l;
1236 let reference = (var_plus / w).sqrt();
1237
1238 assert!(
1240 (got - reference).abs() < 1e-9,
1241 "compute_rhat {got} != directly computed two-pass R-hat {reference}"
1242 );
1243 assert!(
1245 (got - 1.0).abs() > 1e-6,
1246 "compute_rhat collapsed to the spurious 1.0 (got {got})"
1247 );
1248 assert!(got.is_finite(), "compute_rhat must be finite, got {got}");
1249 let via_public = evolutionary_rhat(&[chain1, chain2]);
1251 assert!(
1252 (got - via_public).abs() < 1e-9,
1253 "compute_rhat {got} != evolutionary_rhat {via_public}"
1254 );
1255 }
1256}