Skip to main content

fugue_evo/hyperparameter/
bayesian.rs

1//! Bayesian building blocks and an online operator-parameter tuner.
2//!
3//! This module provides:
4//!
5//! - Honest conjugate posteriors that say what they are: [`BetaPosterior`]
6//!   (Beta-Bernoulli over a probability), [`GammaPosterior`] (Gamma-Exponential
7//!   over a positive *rate*), and [`RunningLogMoments`] (a moment tracker for
8//!   log-scale quantities — *not* a Bayesian posterior, and named accordingly).
9//! - A [`ThompsonSamplingTuner`]: a genuine multi-armed-bandit tuner for GA
10//!   operator parameters. Each tunable parameter is discretized into candidate
11//!   values ("arms"); each arm carries a Beta posterior over
12//!   `P(offspring improves on parents | this value is used)`. Every generation an
13//!   arm is Thompson-sampled per parameter and its **value** is applied to the
14//!   operators; the observed improvement events are credited back to the arm that
15//!   produced them.
16//!
17//! The Beta draw taken during Thompson sampling is used **only** to pick an arm;
18//! it is never returned as the parameter value itself. This is the correct
19//! separation that the previous `BayesianHyperparameterLearner` got wrong (it
20//! sampled a `P(improvement)` posterior and used the draw *as* the mutation rate).
21
22use rand::Rng;
23use rand_distr::{Beta, Distribution, Gamma};
24
25use crate::operators::mutation::{
26    BitFlipMutation, GaussianMutation, PolynomialMutation, UniformMutation,
27};
28
29/// Beta distribution posterior for a probability parameter (Beta-Bernoulli).
30///
31/// Models `θ ∈ [0, 1]` with a `Beta(α, β)` prior; observing a success increments
32/// `α`, a failure increments `β`. The original prior `(α₀, β₀)` is retained so
33/// that [`observations`](Self::observations) can report the true trial count for
34/// *any* prior, not just the uniform one.
35#[derive(Clone, Debug)]
36pub struct BetaPosterior {
37    /// Alpha parameter (prior pseudo-successes + observed successes)
38    pub alpha: f64,
39    /// Beta parameter (prior pseudo-failures + observed failures)
40    pub beta: f64,
41    /// Prior alpha (α₀) — retained for correct observation counting.
42    pub alpha0: f64,
43    /// Prior beta (β₀) — retained for correct observation counting.
44    pub beta0: f64,
45}
46
47impl BetaPosterior {
48    /// Create with uniform prior (α = β = 1)
49    pub fn uniform() -> Self {
50        Self::new(1.0, 1.0)
51    }
52
53    /// Create with Jeffreys prior (α = β = 0.5)
54    pub fn jeffreys() -> Self {
55        Self::new(0.5, 0.5)
56    }
57
58    /// Create with a custom prior `Beta(alpha, beta)`.
59    pub fn new(alpha: f64, beta: f64) -> Self {
60        Self {
61            alpha,
62            beta,
63            alpha0: alpha,
64            beta0: beta,
65        }
66    }
67
68    /// Update posterior with a success observation
69    pub fn observe_success(&mut self) {
70        self.alpha += 1.0;
71    }
72
73    /// Update posterior with a failure observation
74    pub fn observe_failure(&mut self) {
75        self.beta += 1.0;
76    }
77
78    /// Update based on boolean outcome
79    pub fn observe(&mut self, success: bool) {
80        if success {
81            self.observe_success();
82        } else {
83            self.observe_failure();
84        }
85    }
86
87    /// Posterior mean
88    pub fn mean(&self) -> f64 {
89        self.alpha / (self.alpha + self.beta)
90    }
91
92    /// Posterior mode (for α, β > 1)
93    pub fn mode(&self) -> Option<f64> {
94        if self.alpha > 1.0 && self.beta > 1.0 {
95            Some((self.alpha - 1.0) / (self.alpha + self.beta - 2.0))
96        } else {
97            None
98        }
99    }
100
101    /// Posterior variance
102    pub fn variance(&self) -> f64 {
103        let sum = self.alpha + self.beta;
104        (self.alpha * self.beta) / (sum * sum * (sum + 1.0))
105    }
106
107    /// Posterior standard deviation
108    pub fn std_dev(&self) -> f64 {
109        self.variance().sqrt()
110    }
111
112    /// Sample from the posterior
113    pub fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
114        Beta::new(self.alpha, self.beta)
115            .expect("Invalid Beta parameters")
116            .sample(rng)
117    }
118
119    /// 95% credible interval (approximate using normal approximation for large counts)
120    pub fn credible_interval(&self, probability: f64) -> (f64, f64) {
121        let mean = self.mean();
122        let std = self.std_dev();
123        let z = normal_quantile((1.0 + probability) / 2.0);
124        let lower = (mean - z * std).max(0.0);
125        let upper = (mean + z * std).min(1.0);
126        (lower, upper)
127    }
128
129    /// Number of *observed* Bernoulli trials, `(α − α₀) + (β − β₀)`.
130    ///
131    /// This subtracts the stored prior pseudo-counts, so it is correct for any
132    /// prior — uniform `Beta(1, 1)`, Jeffreys `Beta(0.5, 0.5)`, or informative.
133    pub fn observations(&self) -> f64 {
134        (self.alpha - self.alpha0) + (self.beta - self.beta0)
135    }
136
137    /// Apply decay to move toward the stored prior (for non-stationary environments)
138    pub fn decay(&mut self, factor: f64) {
139        self.alpha = self.alpha0 + factor * (self.alpha - self.alpha0);
140        self.beta = self.beta0 + factor * (self.beta - self.beta0);
141    }
142}
143
144impl Default for BetaPosterior {
145    fn default() -> Self {
146        Self::uniform()
147    }
148}
149
150/// Gamma posterior for the **rate** `λ` of an `Exponential(λ)` likelihood.
151///
152/// With a `Gamma(α, β)` prior on `λ` (shape `α`, rate `β`), observing exponential
153/// data `xᵢ` gives the conjugate update `α → α + n`, `β → β + Σxᵢ`. Accordingly:
154///
155/// - [`mean`](Self::mean) `= α / β` is the posterior mean of the **rate** `λ`.
156/// - [`posterior_mean_of_mean`](Self::posterior_mean_of_mean) `= β / (α − 1)` is
157///   the posterior mean of the **mean** `1/λ` (since `1/λ ~ Inverse-Gamma(α, β)`).
158///
159/// Note the two differ by a reciprocal: for exponential data with sample mean `m`
160/// the rate posterior concentrates near `1/m`, while the mean posterior
161/// concentrates near `m`. Callers that want "the average of the observed values"
162/// must use [`posterior_mean_of_mean`](Self::posterior_mean_of_mean), not
163/// [`mean`](Self::mean). Feeding an arbitrary positive *parameter value* in as if
164/// it were exponential data (as the old learner did) is not a coherent model —
165/// tune positive parameters with [`ThompsonSamplingTuner`] instead.
166#[derive(Clone, Debug)]
167pub struct GammaPosterior {
168    /// Shape parameter (α)
169    pub shape: f64,
170    /// Rate parameter (β)
171    pub rate: f64,
172    /// Prior shape (α₀)
173    pub shape0: f64,
174    /// Prior rate (β₀)
175    pub rate0: f64,
176}
177
178impl GammaPosterior {
179    /// Create with a vague prior `Gamma(1, 0.01)`.
180    pub fn vague() -> Self {
181        Self::new(1.0, 0.01)
182    }
183
184    /// Create with a custom prior `Gamma(shape, rate)`.
185    pub fn new(shape: f64, rate: f64) -> Self {
186        Self {
187            shape,
188            rate,
189            shape0: shape,
190            rate0: rate,
191        }
192    }
193
194    /// Conjugate update for one `Exponential(λ)` datum `value`.
195    pub fn observe(&mut self, value: f64) {
196        self.shape += 1.0;
197        self.rate += value;
198    }
199
200    /// Posterior mean of the **rate** `λ` (`= α / β`).
201    pub fn mean(&self) -> f64 {
202        self.shape / self.rate
203    }
204
205    /// Posterior mean of the **mean** `1/λ` (`= β / (α − 1)`, defined for `α > 1`).
206    ///
207    /// `1/λ ~ Inverse-Gamma(α, β)` has mean `β / (α − 1)`. This is the quantity to
208    /// use when you want the posterior estimate of the average of the observed
209    /// positive values.
210    pub fn posterior_mean_of_mean(&self) -> Option<f64> {
211        if self.shape > 1.0 {
212            Some(self.rate / (self.shape - 1.0))
213        } else {
214            None
215        }
216    }
217
218    /// Posterior mode of the rate (for shape ≥ 1)
219    pub fn mode(&self) -> Option<f64> {
220        if self.shape >= 1.0 {
221            Some((self.shape - 1.0) / self.rate)
222        } else {
223            None
224        }
225    }
226
227    /// Posterior variance of the rate
228    pub fn variance(&self) -> f64 {
229        self.shape / (self.rate * self.rate)
230    }
231
232    /// Number of observed exponential data points, `α − α₀`.
233    pub fn observations(&self) -> f64 {
234        self.shape - self.shape0
235    }
236
237    /// Sample the **rate** `λ` from the posterior.
238    pub fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
239        Gamma::new(self.shape, 1.0 / self.rate)
240            .expect("Invalid Gamma parameters")
241            .sample(rng)
242    }
243
244    /// Apply decay toward the stored prior.
245    pub fn decay(&mut self, factor: f64) {
246        self.shape = self.shape0 + factor * (self.shape - self.shape0);
247        self.rate = self.rate0 + factor * (self.rate - self.rate0);
248    }
249}
250
251impl Default for GammaPosterior {
252    fn default() -> Self {
253        Self::vague()
254    }
255}
256
257/// Running mean/variance of `ln(x)` for positive quantities such as step sizes.
258///
259/// This is a numerically-stable Welford moment tracker on the log scale — **not**
260/// a Bayesian posterior (the conjugate model for a log-normal with unknown mean
261/// and variance would be Normal-Inverse-Gamma). It is named to say exactly that.
262///
263/// Unlike the previous `LogNormalPosterior`, the variance is a clean population
264/// variance with **no prior contamination**: after a single observation the
265/// variance is `0`, and there is no spurious `+1.0` sum-of-squares term injected
266/// at `n = 2`.
267#[derive(Clone, Debug, Default)]
268pub struct RunningLogMoments {
269    /// Running mean of `ln(x)`.
270    mean_log: f64,
271    /// Running sum of squared deviations of `ln(x)` (Welford's M2).
272    m2: f64,
273    /// Number of observations.
274    n: usize,
275}
276
277impl RunningLogMoments {
278    /// Create an empty tracker (no observations, no prior).
279    pub fn new() -> Self {
280        Self::default()
281    }
282
283    /// Observe a positive value (non-positive values are ignored).
284    pub fn observe(&mut self, x: f64) {
285        if x <= 0.0 {
286            return;
287        }
288        let log_x = x.ln();
289        self.n += 1;
290        let delta = log_x - self.mean_log;
291        self.mean_log += delta / self.n as f64;
292        let delta2 = log_x - self.mean_log;
293        self.m2 += delta * delta2;
294    }
295
296    /// Number of observations.
297    pub fn count(&self) -> usize {
298        self.n
299    }
300
301    /// Mean of `ln(x)`.
302    pub fn mean_log(&self) -> f64 {
303        self.mean_log
304    }
305
306    /// Population variance of `ln(x)` (`M2 / n`, and `0` when `n < 1`).
307    pub fn var_log(&self) -> f64 {
308        if self.n >= 1 {
309            self.m2 / self.n as f64
310        } else {
311            0.0
312        }
313    }
314
315    /// Unbiased sample variance of `ln(x)` (`M2 / (n − 1)`, `None` when `n < 2`).
316    pub fn sample_var_log(&self) -> Option<f64> {
317        if self.n >= 2 {
318            Some(self.m2 / (self.n as f64 - 1.0))
319        } else {
320            None
321        }
322    }
323
324    /// Mean in the original space, `exp(μ + σ²/2)`.
325    pub fn mean(&self) -> f64 {
326        (self.mean_log + self.var_log() / 2.0).exp()
327    }
328
329    /// Mode in the original space, `exp(μ − σ²)`.
330    pub fn mode(&self) -> f64 {
331        (self.mean_log - self.var_log()).exp()
332    }
333
334    /// Draw a log-normal sample using the current moment estimates.
335    pub fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
336        use rand_distr::StandardNormal;
337        let z: f64 = rng.sample(StandardNormal);
338        (self.mean_log + self.var_log().sqrt() * z).exp()
339    }
340}
341
342/// Mutation operators whose per-gene mutation probability can be set by an online
343/// tuner such as [`ThompsonSamplingTuner`].
344pub trait TunableMutation {
345    /// Set the per-gene mutation probability applied by subsequent `mutate` calls.
346    ///
347    /// Implementations clamp the value into `[0, 1]`.
348    fn set_mutation_probability(&mut self, probability: f64);
349}
350
351macro_rules! impl_tunable_mutation {
352    ($($ty:ty),+ $(,)?) => {
353        $(
354            impl TunableMutation for $ty {
355                fn set_mutation_probability(&mut self, probability: f64) {
356                    self.mutation_probability = Some(probability.clamp(0.0, 1.0));
357                }
358            }
359        )+
360    };
361}
362
363impl_tunable_mutation!(
364    PolynomialMutation,
365    GaussianMutation,
366    UniformMutation,
367    BitFlipMutation,
368);
369
370/// One discretized candidate value for a tunable parameter, with a Beta posterior
371/// over `P(offspring improves on parents | this value is used)`.
372#[derive(Clone, Debug)]
373pub struct BanditArm {
374    /// The concrete parameter value this arm applies.
375    pub value: f64,
376    /// Posterior over `P(improvement)` when this value is used.
377    pub posterior: BetaPosterior,
378    /// Number of times this arm has been selected (pulled).
379    pub selections: u64,
380}
381
382/// A single tunable parameter, discretized into arms and tuned by Thompson
383/// sampling over each arm's Beta posterior.
384#[derive(Clone, Debug)]
385pub struct BanditParameter {
386    /// Human-readable parameter name (e.g. `"mutation_rate"`).
387    pub name: String,
388    arms: Vec<BanditArm>,
389    last_selected: Option<usize>,
390}
391
392impl BanditParameter {
393    /// Create a parameter with the given candidate values and a uniform prior.
394    pub fn new(name: impl Into<String>, values: Vec<f64>) -> Self {
395        Self::with_prior(name, values, BetaPosterior::uniform())
396    }
397
398    /// Create a parameter with the given candidate values and a shared Beta prior.
399    pub fn with_prior(name: impl Into<String>, values: Vec<f64>, prior: BetaPosterior) -> Self {
400        assert!(
401            !values.is_empty(),
402            "BanditParameter requires at least one arm value"
403        );
404        let arms = values
405            .into_iter()
406            .map(|value| BanditArm {
407                value,
408                posterior: prior.clone(),
409                selections: 0,
410            })
411            .collect();
412        Self {
413            name: name.into(),
414            arms,
415            last_selected: None,
416        }
417    }
418
419    /// Thompson-sample an arm and return its parameter value.
420    ///
421    /// Draws one probability from each arm's Beta posterior and selects the arm
422    /// with the highest draw. The draw is used **only** to choose the arm; the
423    /// returned value is the arm's concrete parameter value.
424    pub fn select<R: Rng>(&mut self, rng: &mut R) -> f64 {
425        let mut best_idx = 0;
426        let mut best_draw = f64::NEG_INFINITY;
427        for (i, arm) in self.arms.iter().enumerate() {
428            let draw = arm.posterior.sample(rng);
429            if draw > best_draw {
430                best_draw = draw;
431                best_idx = i;
432            }
433        }
434        self.last_selected = Some(best_idx);
435        self.arms[best_idx].selections += 1;
436        self.arms[best_idx].value
437    }
438
439    /// Credit the most recently selected arm with an improvement outcome.
440    pub fn observe(&mut self, improved: bool) {
441        if let Some(idx) = self.last_selected {
442            self.arms[idx].posterior.observe(improved);
443        }
444    }
445
446    /// The arms of this parameter.
447    pub fn arms(&self) -> &[BanditArm] {
448        &self.arms
449    }
450
451    /// Candidate values, in arm order.
452    pub fn values(&self) -> Vec<f64> {
453        self.arms.iter().map(|a| a.value).collect()
454    }
455
456    /// Posterior mean of `P(improvement)` for each arm, in arm order.
457    pub fn posterior_means(&self) -> Vec<f64> {
458        self.arms.iter().map(|a| a.posterior.mean()).collect()
459    }
460
461    /// Selection (pull) count for each arm, in arm order.
462    pub fn selection_counts(&self) -> Vec<u64> {
463        self.arms.iter().map(|a| a.selections).collect()
464    }
465
466    /// Value chosen at the most recent [`select`](Self::select), if any.
467    pub fn selected_value(&self) -> Option<f64> {
468        self.last_selected.map(|i| self.arms[i].value)
469    }
470
471    /// Arm index chosen at the most recent [`select`](Self::select), if any.
472    pub fn selected_index(&self) -> Option<usize> {
473        self.last_selected
474    }
475
476    /// Index of the arm with the highest posterior mean of `P(improvement)`.
477    pub fn best_index(&self) -> usize {
478        self.arms
479            .iter()
480            .enumerate()
481            .max_by(|(_, a), (_, b)| {
482                a.posterior
483                    .mean()
484                    .partial_cmp(&b.posterior.mean())
485                    .unwrap_or(std::cmp::Ordering::Equal)
486            })
487            .map(|(i, _)| i)
488            .unwrap_or(0)
489    }
490
491    /// Value of the arm with the highest posterior mean of `P(improvement)`.
492    pub fn best_value(&self) -> f64 {
493        self.arms[self.best_index()].value
494    }
495
496    /// Total number of improvement events credited across all arms.
497    pub fn total_observations(&self) -> f64 {
498        self.arms.iter().map(|a| a.posterior.observations()).sum()
499    }
500}
501
502/// Canonical parameter name for the per-gene mutation probability arm set.
503pub const PARAM_MUTATION_RATE: &str = "mutation_rate";
504/// Canonical parameter name for the whole-genome crossover probability arm set.
505pub const PARAM_CROSSOVER_PROB: &str = "crossover_prob";
506
507/// Configuration for a [`ThompsonSamplingTuner`] wired into a GA.
508#[derive(Clone, Debug)]
509pub struct ThompsonConfig {
510    /// Candidate per-gene mutation probabilities (empty ⇒ mutation not tuned).
511    pub mutation_rate_arms: Vec<f64>,
512    /// Candidate whole-genome crossover probabilities (empty ⇒ crossover not tuned).
513    pub crossover_prob_arms: Vec<f64>,
514    /// Beta prior shared by every arm.
515    pub prior: BetaPosterior,
516    /// Record a per-generation posterior snapshot for later inspection.
517    pub record_history: bool,
518}
519
520impl Default for ThompsonConfig {
521    fn default() -> Self {
522        Self {
523            mutation_rate_arms: vec![0.01, 0.05, 0.1, 0.2, 0.4],
524            crossover_prob_arms: vec![0.5, 0.7, 0.9],
525            prior: BetaPosterior::uniform(),
526            record_history: false,
527        }
528    }
529}
530
531impl ThompsonConfig {
532    /// Build a tuner from this configuration.
533    pub fn build_tuner(&self) -> ThompsonSamplingTuner {
534        ThompsonSamplingTuner::from_config(self)
535    }
536}
537
538/// A snapshot of a tuner's per-arm posterior means at one generation.
539#[derive(Clone, Debug)]
540pub struct TunerSnapshot {
541    /// Generation index at which the snapshot was taken.
542    pub generation: usize,
543    /// One entry per parameter: `(name, value selected this generation, posterior means per arm)`.
544    pub parameters: Vec<(String, Option<f64>, Vec<f64>)>,
545}
546
547/// Thompson-sampling multi-armed-bandit tuner over GA operator parameters.
548///
549/// Each parameter ([`BanditParameter`]) is discretized into arms; every generation
550/// [`select_all`](Self::select_all) Thompson-samples one arm per parameter, whose
551/// **value** the caller applies to its operators, and [`observe`](Self::observe)
552/// credits every parameter's selected arm with each improvement event. Distinct
553/// parameters keep independent arms and posteriors, so (unlike the previous
554/// learner) mutation-rate and crossover-probability posteriors are free to diverge.
555#[derive(Clone, Debug)]
556pub struct ThompsonSamplingTuner {
557    parameters: Vec<BanditParameter>,
558    record_history: bool,
559    history: Vec<TunerSnapshot>,
560    observations: u64,
561}
562
563impl ThompsonSamplingTuner {
564    /// Create a tuner from an explicit set of parameters.
565    pub fn new(parameters: Vec<BanditParameter>) -> Self {
566        Self {
567            parameters,
568            record_history: false,
569            history: Vec::new(),
570            observations: 0,
571        }
572    }
573
574    /// Build a tuner from a [`ThompsonConfig`].
575    pub fn from_config(cfg: &ThompsonConfig) -> Self {
576        let mut parameters = Vec::new();
577        if !cfg.mutation_rate_arms.is_empty() {
578            parameters.push(BanditParameter::with_prior(
579                PARAM_MUTATION_RATE,
580                cfg.mutation_rate_arms.clone(),
581                cfg.prior.clone(),
582            ));
583        }
584        if !cfg.crossover_prob_arms.is_empty() {
585            parameters.push(BanditParameter::with_prior(
586                PARAM_CROSSOVER_PROB,
587                cfg.crossover_prob_arms.clone(),
588                cfg.prior.clone(),
589            ));
590        }
591        Self {
592            parameters,
593            record_history: cfg.record_history,
594            history: Vec::new(),
595            observations: 0,
596        }
597    }
598
599    /// Enable or disable per-generation snapshot recording.
600    pub fn with_history(mut self, on: bool) -> Self {
601        self.record_history = on;
602        self
603    }
604
605    /// All tuned parameters.
606    pub fn parameters(&self) -> &[BanditParameter] {
607        &self.parameters
608    }
609
610    /// Look up a parameter by name.
611    pub fn parameter(&self, name: &str) -> Option<&BanditParameter> {
612        self.parameters.iter().find(|p| p.name == name)
613    }
614
615    /// Look up a parameter by name (mutable).
616    pub fn parameter_mut(&mut self, name: &str) -> Option<&mut BanditParameter> {
617        self.parameters.iter_mut().find(|p| p.name == name)
618    }
619
620    /// Whether the tuner has no parameters to tune.
621    pub fn is_empty(&self) -> bool {
622        self.parameters.is_empty()
623    }
624
625    /// Thompson-sample one arm per parameter for the coming generation.
626    pub fn select_all<R: Rng>(&mut self, rng: &mut R) {
627        for p in &mut self.parameters {
628            p.select(rng);
629        }
630    }
631
632    /// Value selected for `name` at the most recent [`select_all`](Self::select_all).
633    pub fn selected(&self, name: &str) -> Option<f64> {
634        self.parameter(name).and_then(|p| p.selected_value())
635    }
636
637    /// Credit every parameter's currently selected arm with one improvement event.
638    pub fn observe(&mut self, improved: bool) {
639        for p in &mut self.parameters {
640            p.observe(improved);
641        }
642        self.observations += 1;
643    }
644
645    /// Total number of improvement events fed back to the tuner.
646    pub fn total_observations(&self) -> u64 {
647        self.observations
648    }
649
650    /// Record a snapshot of the current posteriors (no-op unless history is enabled).
651    pub fn snapshot(&mut self, generation: usize) {
652        if !self.record_history {
653            return;
654        }
655        let parameters = self
656            .parameters
657            .iter()
658            .map(|p| (p.name.clone(), p.selected_value(), p.posterior_means()))
659            .collect();
660        self.history.push(TunerSnapshot {
661            generation,
662            parameters,
663        });
664    }
665
666    /// Recorded per-generation snapshots (empty unless history is enabled).
667    pub fn history(&self) -> &[TunerSnapshot] {
668        &self.history
669    }
670}
671
672/// Approximate normal quantile function
673fn normal_quantile(p: f64) -> f64 {
674    // Rational approximation for normal quantile
675    // Good enough for credible interval computation
676    if p <= 0.0 {
677        return f64::NEG_INFINITY;
678    }
679    if p >= 1.0 {
680        return f64::INFINITY;
681    }
682
683    let t = if p < 0.5 {
684        (-2.0 * p.ln()).sqrt()
685    } else {
686        (-2.0 * (1.0 - p).ln()).sqrt()
687    };
688
689    let c0 = 2.515517;
690    let c1 = 0.802853;
691    let c2 = 0.010328;
692    let d1 = 1.432788;
693    let d2 = 0.189269;
694    let d3 = 0.001308;
695
696    let q = t - (c0 + c1 * t + c2 * t * t) / (1.0 + d1 * t + d2 * t * t + d3 * t * t * t);
697
698    if p < 0.5 {
699        -q
700    } else {
701        q
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use rand::rngs::StdRng;
709    use rand::SeedableRng;
710
711    #[test]
712    fn test_beta_posterior_uniform_prior() {
713        let posterior = BetaPosterior::uniform();
714        assert!((posterior.mean() - 0.5).abs() < 1e-10);
715    }
716
717    #[test]
718    fn test_beta_posterior_update() {
719        let mut posterior = BetaPosterior::uniform();
720
721        // Observe 7 successes, 3 failures
722        for _ in 0..7 {
723            posterior.observe_success();
724        }
725        for _ in 0..3 {
726            posterior.observe_failure();
727        }
728
729        // Expected mean: (1 + 7) / (1 + 7 + 1 + 3) = 8/12 = 0.667
730        assert!((posterior.mean() - 0.667).abs() < 0.01);
731    }
732
733    #[test]
734    fn test_beta_posterior_sample() {
735        let posterior = BetaPosterior::new(5.0, 5.0);
736        let mut rng = rand::thread_rng();
737
738        for _ in 0..100 {
739            let sample = posterior.sample(&mut rng);
740            assert!((0.0..=1.0).contains(&sample));
741        }
742    }
743
744    /// regression: EV-95 — observations() must subtract the *stored* prior, so it
745    /// reports the true trial count for Jeffreys/informative priors, not just uniform.
746    #[test]
747    fn test_beta_observations_uses_stored_prior() {
748        // Uniform prior: 10 trials -> 10 observations.
749        let mut uniform = BetaPosterior::uniform();
750        for i in 0..10 {
751            uniform.observe(i % 2 == 0);
752        }
753        assert!((uniform.observations() - 10.0).abs() < 1e-12);
754
755        // Jeffreys prior Beta(0.5, 0.5): 5 successes + 5 failures -> 10 observations.
756        // The pre-fix formula (alpha + beta - 2) would report 11 - 2 = 9 here.
757        let mut jeffreys = BetaPosterior::jeffreys();
758        for _ in 0..5 {
759            jeffreys.observe_success();
760        }
761        for _ in 0..5 {
762            jeffreys.observe_failure();
763        }
764        assert!((jeffreys.observations() - 10.0).abs() < 1e-12);
765
766        // Informative prior Beta(2, 2): 4 trials -> 4 observations.
767        let mut informative = BetaPosterior::new(2.0, 2.0);
768        for _ in 0..4 {
769            informative.observe_success();
770        }
771        assert!((informative.observations() - 4.0).abs() < 1e-12);
772    }
773
774    /// regression: EV-22 — mean() is the posterior mean of the RATE, and
775    /// posterior_mean_of_mean() = β/(α−1) recovers the mean of the observed values.
776    #[test]
777    fn test_gamma_rate_and_mean_of_mean() {
778        // Hand-computed conjugate update: prior Gamma(2, 1), observe [1, 2, 3].
779        // Posterior = Gamma(2 + 3, 1 + 6) = Gamma(5, 7).
780        let mut posterior = GammaPosterior::new(2.0, 1.0);
781        for x in [1.0, 2.0, 3.0] {
782            posterior.observe(x);
783        }
784        assert!((posterior.shape - 5.0).abs() < 1e-12);
785        assert!((posterior.rate - 7.0).abs() < 1e-12);
786        // Posterior mean of the RATE = 5/7.
787        assert!((posterior.mean() - 5.0 / 7.0).abs() < 1e-12);
788        // Posterior mean of the MEAN = 7/(5-1) = 1.75.
789        assert!((posterior.posterior_mean_of_mean().unwrap() - 1.75).abs() < 1e-12);
790        assert!((posterior.observations() - 3.0).abs() < 1e-12);
791    }
792
793    /// regression: EV-22 — observing value 20 repeatedly drives the RATE posterior
794    /// toward 1/20 (=0.05), while posterior_mean_of_mean() recovers ~20 — the value
795    /// the old code could never produce (it returned the reciprocal as the parameter).
796    #[test]
797    fn test_gamma_recovers_mean_not_reciprocal() {
798        let mut posterior = GammaPosterior::vague();
799        for _ in 0..1000 {
800            posterior.observe(20.0);
801        }
802        assert!((posterior.mean() - 0.05).abs() < 0.005, "rate ~ 1/20");
803        let mean = posterior.posterior_mean_of_mean().unwrap();
804        assert!((mean - 20.0).abs() < 0.5, "mean-of-mean ~ 20, got {mean}");
805    }
806
807    /// regression: EV-61 — the log-moment tracker must not contaminate the variance
808    /// with a prior. After one observation the variance is 0; after two it is the
809    /// exact population variance (the old code injected a spurious +1.0 term).
810    #[test]
811    fn test_running_log_moments_no_prior_contamination() {
812        let mut moments = RunningLogMoments::new();
813
814        // Single observation -> variance is exactly 0 (old code left it at 1.0).
815        moments.observe(std::f64::consts::E); // ln = 1
816        assert!((moments.var_log()).abs() < 1e-12);
817        assert!((moments.mean_log() - 1.0).abs() < 1e-12);
818
819        // Second observation ln = 3. Population var of {1, 3} = 1.0, sample var = 2.0.
820        // The old (contaminated) recursion produced 1.5.
821        moments.observe(std::f64::consts::E.powi(3)); // ln = 3
822        assert!((moments.mean_log() - 2.0).abs() < 1e-12);
823        assert!((moments.var_log() - 1.0).abs() < 1e-12);
824        assert!((moments.sample_var_log().unwrap() - 2.0).abs() < 1e-12);
825    }
826
827    #[test]
828    fn test_running_log_moments_mean_original_space() {
829        let mut moments = RunningLogMoments::new();
830        for _ in 0..10 {
831            moments.observe(0.1);
832        }
833        // All observations equal -> variance 0 -> mean = 0.1 exactly.
834        assert!((moments.mean() - 0.1).abs() < 1e-12);
835    }
836
837    #[test]
838    fn test_bandit_parameter_thompson_selects_a_value() {
839        let mut param = BanditParameter::new(PARAM_MUTATION_RATE, vec![0.01, 0.1, 0.3]);
840        let mut rng = StdRng::seed_from_u64(1);
841        let v = param.select(&mut rng);
842        assert!([0.01, 0.1, 0.3].contains(&v));
843        param.observe(true);
844        assert!((param.total_observations() - 1.0).abs() < 1e-12);
845    }
846
847    /// regression: EV-23 — an honest Thompson-sampling bandit must concentrate its
848    /// pulls on the dominant arm. Here mutation-rate 0.3 truly improves offspring
849    /// 55% of the time and 0.01 only 20%; after learning, >70% of late pulls land
850    /// on 0.3. (The old design sampled a single P(improvement) posterior and used
851    /// the draw *as* the rate, so it had no per-arm concentration at all.)
852    #[test]
853    fn test_bandit_concentrates_on_better_arm() {
854        let mut rng = StdRng::seed_from_u64(20260710);
855        let mut param = BanditParameter::new(PARAM_MUTATION_RATE, vec![0.01, 0.3]);
856
857        // True improvement probabilities per arm value.
858        let true_p = |v: f64| if v >= 0.3 { 0.55 } else { 0.20 };
859
860        let total_rounds = 2000;
861        let late_start = 1500;
862        let mut late_good = 0u32;
863        let mut late_total = 0u32;
864
865        for round in 0..total_rounds {
866            let value = param.select(&mut rng);
867            let improved = rng.gen::<f64>() < true_p(value);
868            param.observe(improved);
869            if round >= late_start {
870                late_total += 1;
871                if value >= 0.3 {
872                    late_good += 1;
873                }
874            }
875        }
876
877        let frac = late_good as f64 / late_total as f64;
878        assert!(
879            frac > 0.70,
880            "expected >70% of late pulls on the better arm, got {:.2}",
881            frac
882        );
883        // The learner should also report 0.3 as the best arm.
884        assert!((param.best_value() - 0.3).abs() < 1e-12);
885    }
886
887    #[test]
888    fn test_thompson_tuner_from_config() {
889        let cfg = ThompsonConfig::default();
890        let mut tuner = cfg.build_tuner();
891        assert!(tuner.parameter(PARAM_MUTATION_RATE).is_some());
892        assert!(tuner.parameter(PARAM_CROSSOVER_PROB).is_some());
893
894        let mut rng = StdRng::seed_from_u64(7);
895        tuner.select_all(&mut rng);
896        assert!(tuner.selected(PARAM_MUTATION_RATE).is_some());
897        assert!(tuner.selected(PARAM_CROSSOVER_PROB).is_some());
898
899        tuner.observe(true);
900        tuner.observe(false);
901        assert_eq!(tuner.total_observations(), 2);
902    }
903
904    /// The two parameters keep independent posteriors — they cannot be forced
905    /// identical the way the old learner's duplicated posteriors were.
906    #[test]
907    fn test_thompson_tuner_parameters_are_independent() {
908        let cfg = ThompsonConfig {
909            mutation_rate_arms: vec![0.1, 0.3],
910            crossover_prob_arms: vec![0.5, 0.9],
911            prior: BetaPosterior::uniform(),
912            record_history: false,
913        };
914        let mut tuner = cfg.build_tuner();
915        let mut rng = StdRng::seed_from_u64(99);
916        for _ in 0..50 {
917            tuner.select_all(&mut rng);
918            tuner.observe(true);
919        }
920        let mr = tuner.parameter(PARAM_MUTATION_RATE).unwrap();
921        let cx = tuner.parameter(PARAM_CROSSOVER_PROB).unwrap();
922        // Distinct arm value sets prove they are genuinely separate bandits.
923        assert_eq!(mr.values(), vec![0.1, 0.3]);
924        assert_eq!(cx.values(), vec![0.5, 0.9]);
925    }
926
927    #[test]
928    fn test_tunable_mutation_sets_probability() {
929        let mut m = GaussianMutation::new(0.1);
930        m.set_mutation_probability(0.25);
931        assert_eq!(m.mutation_probability, Some(0.25));
932        // Clamped into [0, 1].
933        m.set_mutation_probability(5.0);
934        assert_eq!(m.mutation_probability, Some(1.0));
935    }
936
937    #[test]
938    fn test_credible_interval() {
939        let posterior = BetaPosterior::new(50.0, 50.0);
940        let (lower, upper) = posterior.credible_interval(0.95);
941
942        assert!(lower < 0.5);
943        assert!(upper > 0.5);
944        assert!(lower > 0.0);
945        assert!(upper < 1.0);
946    }
947}