Skip to main content

fugue_evo/diagnostics/
convergence.rs

1//! Convergence detection for evolutionary algorithms
2//!
3//! This module provides various methods to detect when an evolutionary algorithm
4//! has converged or should terminate.
5
6use serde::{Deserialize, Serialize};
7
8/// Result of a convergence check
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum ConvergenceStatus {
11    /// Algorithm has not converged
12    NotConverged,
13    /// Algorithm has converged with a reason
14    Converged(ConvergenceReason),
15}
16
17impl ConvergenceStatus {
18    /// Check if converged
19    pub fn is_converged(&self) -> bool {
20        matches!(self, Self::Converged(_))
21    }
22}
23
24/// Reason for convergence
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ConvergenceReason {
27    /// Fitness has not improved for many generations
28    FitnessStagnation { generations: usize },
29    /// Population diversity is below threshold
30    LowDiversity { diversity: u64 }, // Store as bits for Eq
31    /// Target fitness reached
32    TargetReached { target: u64 }, // Store as bits for Eq
33    /// Maximum generations reached
34    MaxGenerations { generations: usize },
35    /// Maximum evaluations reached
36    MaxEvaluations { evaluations: usize },
37    /// R-hat statistic indicates convergence
38    RhatConverged { rhat: u64 }, // Store as bits for Eq
39    /// Multiple criteria satisfied
40    MultipleReasons(Vec<ConvergenceReason>),
41    /// Custom termination
42    Custom(String),
43}
44
45impl ConvergenceReason {
46    /// Create a fitness stagnation reason
47    pub fn fitness_stagnation(generations: usize) -> Self {
48        Self::FitnessStagnation { generations }
49    }
50
51    /// Create a low diversity reason
52    pub fn low_diversity(diversity: f64) -> Self {
53        Self::LowDiversity {
54            diversity: diversity.to_bits(),
55        }
56    }
57
58    /// Create a target reached reason
59    pub fn target_reached(target: f64) -> Self {
60        Self::TargetReached {
61            target: target.to_bits(),
62        }
63    }
64
65    /// Create an R-hat converged reason
66    pub fn rhat_converged(rhat: f64) -> Self {
67        Self::RhatConverged {
68            rhat: rhat.to_bits(),
69        }
70    }
71}
72
73/// Configuration for convergence detection
74#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct ConvergenceConfig {
76    /// Maximum generations before termination
77    pub max_generations: Option<usize>,
78    /// Maximum fitness evaluations before termination
79    pub max_evaluations: Option<usize>,
80    /// Target fitness to reach
81    pub target_fitness: Option<f64>,
82    /// Tolerance for target fitness comparison
83    pub target_tolerance: f64,
84    /// Number of generations without improvement before stagnation
85    pub stagnation_generations: usize,
86    /// Minimum improvement to not count as stagnation
87    pub stagnation_threshold: f64,
88    /// Diversity threshold below which convergence is detected
89    pub diversity_threshold: f64,
90    /// R-hat threshold for convergence (typically 1.1)
91    pub rhat_threshold: f64,
92    /// Whether to use R-hat based convergence
93    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    /// Create a new config with max generations
114    pub fn with_max_generations(generations: usize) -> Self {
115        Self {
116            max_generations: Some(generations),
117            ..Default::default()
118        }
119    }
120
121    /// Set max generations
122    pub fn max_generations(mut self, generations: usize) -> Self {
123        self.max_generations = Some(generations);
124        self
125    }
126
127    /// Set max evaluations
128    pub fn max_evaluations(mut self, evaluations: usize) -> Self {
129        self.max_evaluations = Some(evaluations);
130        self
131    }
132
133    /// Set target fitness
134    pub fn target_fitness(mut self, target: f64) -> Self {
135        self.target_fitness = Some(target);
136        self
137    }
138
139    /// Set target tolerance
140    pub fn target_tolerance(mut self, tolerance: f64) -> Self {
141        self.target_tolerance = tolerance;
142        self
143    }
144
145    /// Set stagnation detection parameters
146    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    /// Set diversity threshold
153    pub fn diversity_threshold(mut self, threshold: f64) -> Self {
154        self.diversity_threshold = threshold;
155        self
156    }
157
158    /// Enable R-hat based convergence
159    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/// Convergence detector that tracks evolution state
167#[derive(Clone, Debug)]
168pub struct ConvergenceDetector {
169    /// Configuration
170    config: ConvergenceConfig,
171    /// History of best fitness values
172    best_fitness_history: Vec<f64>,
173    /// History of mean fitness values (for R-hat). Retained in full so
174    /// `compute_rhat` can take a numerically stable **two-pass** variance over
175    /// each half-chain (see `compute_rhat`); an earlier revision kept running
176    /// sum / sum-of-squares prefix arrays instead, but the one-pass variance
177    /// they enabled was catastrophically unstable for large-offset fitness.
178    mean_fitness_history: Vec<f64>,
179    /// History of diversity values
180    diversity_history: Vec<f64>,
181    /// Current generation
182    current_generation: usize,
183    /// Current evaluations
184    current_evaluations: usize,
185    /// Best fitness seen so far, *thresholded* for stagnation tracking: only
186    /// advanced when an update improves on it by more than `stagnation_threshold`
187    /// (see `update`). Because of that throttle it can lag the true running max by
188    /// up to `stagnation_threshold`, so it must NOT be used for target detection.
189    best_fitness_overall: f64,
190    /// Pure running maximum of every `best_fitness` ever passed to `update`, with
191    /// no threshold throttle (REG-1). This is the authoritative "best seen so far"
192    /// used by `best_fitness()` and the target-fitness check; keeping it separate
193    /// from `best_fitness_overall` lets stagnation stay throttled while target
194    /// detection sees the true best.
195    running_best_fitness: f64,
196    /// Generation when best fitness was last improved
197    last_improvement_generation: usize,
198}
199
200/// Numerically stable **two-pass** sample mean and (Bessel-corrected) variance
201/// of `xs`, returned as `(mean, variance)`.
202///
203/// The first pass computes the mean; the second sums squared deviations from
204/// that mean. This avoids the catastrophic cancellation of the one-pass
205/// `(Σx² − (Σx)²/n)/(n−1)` form, which loses precision when the values share a
206/// large offset relative to their spread (the exact failure that motivated
207/// this helper — see `compute_rhat`). `xs` must be non-empty; the variance is
208/// `0.0` for a single element. The operations mirror those in
209/// [`evolutionary_rhat`], so the two agree to within rounding.
210fn 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    /// Create a new convergence detector
222    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    /// Create with default config
237    pub fn with_defaults() -> Self {
238        Self::new(ConvergenceConfig::default())
239    }
240
241    /// Update with generation statistics
242    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        // Pure running max (REG-1): tracks the true best regardless of the
257        // stagnation throttle below, so target detection never lags.
258        if best_fitness > self.running_best_fitness {
259            self.running_best_fitness = best_fitness;
260        }
261
262        // Track improvement (stagnation): intentionally throttled — only counts as
263        // an improvement when it beats the previous best by more than
264        // `stagnation_threshold`, so tiny gains don't reset the stagnation clock.
265        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    /// Check if algorithm has converged
272    pub fn check(&self) -> ConvergenceStatus {
273        let mut reasons = Vec::new();
274
275        // Check max generations
276        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        // Check max evaluations
285        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        // Check target fitness.
294        // EV-49 / REG-1: read the true running best (`running_best_fitness`, the
295        // same value returned by `best_fitness()`), not the last per-generation
296        // value and NOT the stagnation-throttled `best_fitness_overall`. A caller
297        // may legitimately pass a non-monotonic per-generation best, so
298        // `best_fitness_history.last()` can dip below a target already reached in
299        // an earlier generation; and `best_fitness_overall` lags the true best by
300        // up to `stagnation_threshold`, which would miss a reached target whenever
301        // `stagnation_threshold > target_tolerance`. The pure running max keeps
302        // target detection consistent with the struct's own `best_fitness()`.
303        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        // Check stagnation
313        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        // Check diversity
322        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        // Check R-hat if enabled
329        if self.config.use_rhat && self.mean_fitness_history.len() >= 10 {
330            // Split history into "chains" for R-hat calculation
331            let rhat = self.compute_rhat();
332            if rhat < self.config.rhat_threshold {
333                reasons.push(ConvergenceReason::rhat_converged(rhat));
334            }
335        }
336
337        // Return result
338        match reasons.len() {
339            0 => ConvergenceStatus::NotConverged,
340            1 => ConvergenceStatus::Converged(reasons.pop().unwrap()),
341            _ => ConvergenceStatus::Converged(ConvergenceReason::MultipleReasons(reasons)),
342        }
343    }
344
345    /// Compute the split-R-hat statistic (Gelman & Rubin) from the mean-fitness
346    /// history.
347    ///
348    /// The history is split into two equal-length half-chains — indices
349    /// `[0, l)` and `[l, 2l)` where `l = len / 2` — matching the common-length
350    /// truncation performed by [`evolutionary_rhat`]; the returned value equals
351    /// `evolutionary_rhat(&[history[0..l], history[l..2l]])`.
352    ///
353    /// Each half-chain's mean and (Bessel-corrected) within-chain variance are
354    /// computed with a numerically stable **two-pass** formula (subtract the
355    /// chain mean, then sum squared deviations) directly over the raw history.
356    /// This replaces an earlier one-pass `(Σx² − (Σx)²/l)/(l−1)` form evaluated
357    /// over running sum / sum-of-squares prefix arrays. That form suffered
358    /// catastrophic cancellation for large-offset fitness (e.g. mean-fitness
359    /// ~1e6 with a true within-chain variance ~1 lost ~12 significant digits):
360    /// it could drive `w <= 0` and report a spurious R-hat of exactly `1.0`
361    /// ("converged"), and the running Σx² could overflow to `+inf` over very
362    /// long runs, yielding `NaN`. The two-pass form has no such cancellation.
363    ///
364    /// Returns `f64::INFINITY` when there are fewer than 5 draws per half-chain.
365    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; // Not enough data
371        }
372
373        let l_f = l as f64;
374
375        // chain1 = indices [0, l), chain2 = indices [l, 2l) — matching the
376        // common-length truncation performed by `evolutionary_rhat`.
377        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; // Perfect convergence (identical chains)
387        }
388
389        let var_plus = ((l_f - 1.0) / l_f) * w + b / l_f;
390        (var_plus / w).sqrt()
391    }
392
393    /// Get the best fitness seen (true running maximum, not the
394    /// stagnation-throttled bookkeeping value).
395    pub fn best_fitness(&self) -> f64 {
396        self.running_best_fitness
397    }
398
399    /// Get generations since last improvement
400    pub fn generations_without_improvement(&self) -> usize {
401        self.current_generation - self.last_improvement_generation
402    }
403
404    /// Get the latest diversity value
405    pub fn current_diversity(&self) -> Option<f64> {
406        self.diversity_history.last().copied()
407    }
408
409    /// Get the fitness history
410    pub fn fitness_history(&self) -> &[f64] {
411        &self.best_fitness_history
412    }
413
414    /// Get the diversity history
415    pub fn diversity_history(&self) -> &[f64] {
416        &self.diversity_history
417    }
418
419    /// Reset the detector
420    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
432/// R-hat analog for evolutionary convergence
433///
434/// Compares fitness distributions across multiple runs/chains.
435/// Values close to 1.0 indicate convergence.
436pub 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    // EV-14: the split-R-hat statistic (Gelman & Rubin, 1992) is defined for
443    // equal-length chains. When chains differ in length we truncate every chain
444    // to the common minimum `n` (standard practice) and use ONLY the first `n`
445    // draws of each chain for both the mean and the sum-of-squares. The previous
446    // code summed over the full (possibly longer) chain while dividing by the
447    // shorter `n`, corrupting R-hat whenever chain lengths differed.
448    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    // Between-chain variance (each chain truncated to its first `n` draws)
456    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    // Within-chain variance (each chain truncated to its first `n` draws)
468    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; // Perfect convergence
480    }
481
482    // Pooled variance estimate
483    let var_plus = ((n - 1.0) / n) * w + b / n;
484
485    (var_plus / w).sqrt()
486}
487
488/// Effective Sample Size (ESS) for evolutionary SMC
489///
490/// Measures the effective number of independent samples based on importance weights.
491pub fn evolutionary_ess(weights: &[f64]) -> f64 {
492    if weights.is_empty() {
493        return 0.0;
494    }
495
496    // Normalize weights
497    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
512/// Effective Sample Size from log weights
513pub fn evolutionary_ess_log(log_weights: &[f64]) -> f64 {
514    if log_weights.is_empty() {
515        return 0.0;
516    }
517
518    // Use log-sum-exp trick for numerical stability
519    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
532/// Detect fitness stagnation in a history of fitness values
533///
534/// Returns the number of generations the fitness has been stagnant.
535pub 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    // Count generations since best was improved
546    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) // Don't count the best itself
556}
557
558/// Compute population convergence from fitness values
559///
560/// Returns a value between 0 (no convergence) and 1 (perfect convergence)
561/// based on the coefficient of variation of fitness values.
562pub 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    // Coefficient of variation (CV)
580    let cv = std / mean.abs();
581
582    // Convert to convergence metric (higher = more converged)
583    // CV of 0 means perfect convergence
584    // Use exponential decay so that small CV gives high convergence
585    (-cv).exp()
586}
587
588/// Termination criteria for evolutionary algorithms
589#[derive(Clone, Debug)]
590pub struct TerminationCriteria {
591    criteria: Vec<TerminationCriterion>,
592    require_all: bool,
593}
594
595/// A single termination criterion
596#[derive(Clone, Debug)]
597pub enum TerminationCriterion {
598    /// Maximum generations
599    MaxGenerations(usize),
600    /// Maximum evaluations
601    MaxEvaluations(usize),
602    /// Target fitness (maximize)
603    TargetFitness(f64, f64), // (target, tolerance)
604    /// Fitness stagnation
605    Stagnation(usize, f64), // (generations, threshold)
606    /// Diversity threshold
607    DiversityThreshold(f64),
608    /// Time limit in seconds
609    TimeLimit(f64),
610    /// Custom predicate
611    Custom(String), // Description only, evaluation handled externally
612}
613
614impl TerminationCriteria {
615    /// Create new empty criteria (any criterion triggers termination)
616    pub fn new() -> Self {
617        Self {
618            criteria: Vec::new(),
619            require_all: false,
620        }
621    }
622
623    /// Create criteria where all must be satisfied
624    pub fn require_all() -> Self {
625        Self {
626            criteria: Vec::new(),
627            require_all: true,
628        }
629    }
630
631    /// Add a criterion
632    pub fn add(mut self, criterion: TerminationCriterion) -> Self {
633        self.criteria.push(criterion);
634        self
635    }
636
637    /// Add max generations criterion
638    pub fn max_generations(self, generations: usize) -> Self {
639        self.add(TerminationCriterion::MaxGenerations(generations))
640    }
641
642    /// Add max evaluations criterion
643    pub fn max_evaluations(self, evaluations: usize) -> Self {
644        self.add(TerminationCriterion::MaxEvaluations(evaluations))
645    }
646
647    /// Add target fitness criterion
648    pub fn target_fitness(self, target: f64, tolerance: f64) -> Self {
649        self.add(TerminationCriterion::TargetFitness(target, tolerance))
650    }
651
652    /// Add stagnation criterion
653    pub fn stagnation(self, generations: usize, threshold: f64) -> Self {
654        self.add(TerminationCriterion::Stagnation(generations, threshold))
655    }
656
657    /// Add diversity threshold criterion
658    pub fn diversity_threshold(self, threshold: f64) -> Self {
659        self.add(TerminationCriterion::DiversityThreshold(threshold))
660    }
661
662    /// Add time limit criterion
663    pub fn time_limit(self, seconds: f64) -> Self {
664        self.add(TerminationCriterion::TimeLimit(seconds))
665    }
666
667    /// Check if termination criteria are met.
668    ///
669    /// EV-50: the `Stagnation(generations, threshold)` criterion now computes its
670    /// own stagnation count from `fitness_history` using its configured
671    /// `threshold` (via [`detect_stagnation`]), instead of ignoring the threshold
672    /// and trusting a pre-computed count. Pass the running best-fitness history so
673    /// the threshold configured through the builder is actually honored.
674    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, // Handled externally
698            };
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        // Return the reason(s)
719        if satisfied.len() == 1 {
720            Some(satisfied.pop().unwrap())
721        } else {
722            Some(ConvergenceReason::MultipleReasons(satisfied))
723        }
724    }
725
726    /// Get all criteria
727    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        // Simulate improving fitness
770        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        // First improvement
813        detector.update(0, 10, 50.0, 50.0, 0.5);
814
815        // Then stagnation
816        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        // Similar chains with some variation should give R-hat close to 1
847        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        // R-hat should be close to 1 for similar chains (typically < 1.1 for convergence)
851        assert!(rhat < 1.2, "R-hat was {}, expected < 1.2", rhat);
852    }
853
854    #[test]
855    fn test_evolutionary_rhat_divergent() {
856        // Very different chains with internal variation should give high R-hat
857        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        // R-hat should be high for divergent chains
863        assert!(rhat > 1.5, "R-hat was {}, expected > 1.5", rhat);
864    }
865
866    #[test]
867    fn test_evolutionary_ess() {
868        // Equal weights should give ESS = n
869        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        // One dominant weight should give low ESS
877        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); // 3 generations at max
894    }
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        // All same fitness = perfect convergence
906        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        // Very diverse fitness = low convergence
914        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        // Neither met
948        let result = criteria.should_terminate(10, 100, 50.0, 0.5, &[], 5.0);
949        assert!(result.is_none());
950
951        // Target met
952        let result = criteria.should_terminate(10, 100, 100.0, 0.5, &[], 5.0);
953        assert!(result.is_some());
954
955        // Max gen met
956        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        // Only max gen met: a 6-long flat history yields a stagnation count of 5
967        // (< 10), so the stagnation criterion is not satisfied.
968        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        // Both met: an 11-long flat history yields a stagnation count of 10 (>= 10).
973        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    // regression: EV-14 — unequal-length chains are truncated to the common
1027    // minimum before computing means/variances. chain1=[1,2,3,4] and a chain2
1028    // with an extra 5th draw truncate to identical [1,2,3,4], giving the exact
1029    // R-hat = sqrt(0.75). The pre-fix code summed the full chain2 while dividing
1030    // by the shorter n, yielding ~1.0066 instead.
1031    #[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    // regression: EV-49 — once the running best reaches the target, a later
1048    // per-generation dip below the target must not un-converge the detector. The
1049    // pre-fix code read best_fitness_history.last() (the dipped value) and failed
1050    // to report TargetReached.
1051    #[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); // keep stagnation from firing
1057        let mut detector = ConvergenceDetector::new(config);
1058
1059        detector.update(0, 10, 100.0, 50.0, 1.0); // running best hits target
1060        detector.update(1, 20, 50.0, 50.0, 1.0); // per-generation best dips below
1061
1062        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    // regression: REG-1 — the EV-49 fix must read a *pure* running max, not the
1074    // stagnation-throttled `best_fitness_overall`, which lags the true best by up
1075    // to `stagnation_threshold`. With `stagnation_threshold > target_tolerance`,
1076    // a target that is reached-and-held is otherwise never reported.
1077    #[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            // threshold (0.01) deliberately larger than the tolerance (1e-6)
1083            .stagnation(50, 0.01);
1084        let mut detector = ConvergenceDetector::new(config);
1085
1086        // gen0 seeds the throttled `best_fitness_overall` just below target.
1087        detector.update(0, 10, 99.995, 50.0, 1.0);
1088        // gen1 hits the target exactly, but 100.0 <= 99.995 + 0.01 = 100.005, so
1089        // the throttled value stays at 99.995. The pure running max must be 100.0.
1090        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        // Hold at target for many generations: TargetReached must persist and the
1114        // pre-fix wrong-reason (stagnation ~49 gens later) must not be the sole
1115        // reason reported at the moment the target is first reached.
1116        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    // regression: EV-50 — the configured stagnation threshold is actually honored.
1134    // The same history is stagnant under a loose threshold but not a tight one.
1135    // Pre-fix, the threshold was discarded (bound to `_threshold`) and an external
1136    // pre-computed count was used, so both thresholds behaved identically.
1137    #[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    // regression: EV-88 — `compute_rhat` must equal the from-scratch
1159    // `evolutionary_rhat` recompute over the full mean-fitness history at every
1160    // length, proving the half-chain split and two-pass statistics stay
1161    // behavior-identical to the reference implementation.
1162    #[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    // regression (re-verification low): `compute_rhat` previously derived each
1190    // half-chain's within-chain variance from running sum / sum-of-squares
1191    // prefix arrays via `(sq - sum*sum/l)/(l-1)`. With mean-fitness values
1192    // offset by ~1e6 and a true within-chain variance of ~1, that one-pass form
1193    // subtracts two ~2e13 quantities to recover ~19, losing ~12 significant
1194    // digits: it can drive `w <= 0` and report a spurious R-hat of exactly 1.0
1195    // (false "converged"), and the running Σx² can overflow to +inf (→ NaN)
1196    // over long runs. The stable two-pass formulation must (a) match a directly
1197    // computed two-pass R-hat to 1e-9 and (b) NOT collapse to the spurious 1.0.
1198    #[test]
1199    fn test_compute_rhat_stable_under_large_offset() {
1200        // Two chains sharing a ~1e6 offset, each with small, distinct
1201        // deviations (true within-chain variance ~1) plus a real between-chain
1202        // mean shift of ~4 — so the correct R-hat is clearly > 1.
1203        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        // History = chain1 then chain2, so with len = 40 the half-chain split
1212        // (`l = 20`) reproduces exactly [chain1, chain2].
1213        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        // Directly compute the reference two-pass R-hat, independently of the
1220        // module helper (subtract mean, then sum squared deviations).
1221        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        // (a) stable formulation matches the directly computed two-pass R-hat.
1239        assert!(
1240            (got - reference).abs() < 1e-9,
1241            "compute_rhat {got} != directly computed two-pass R-hat {reference}"
1242        );
1243        // (b) must NOT report the spurious `w <= 0` convergence value of 1.0.
1244        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        // And it agrees with the public reference over the same two chains.
1250        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}