Skip to main content

fugue_evo/inference/
smc.rs

1//! Tempered SMC over the Boltzmann posterior, built on fugue's SMC engine
2//!
3//! The old `EvolutionarySMC` hand-rolled the whole tempering loop (linear β
4//! ladder, weight normalization, ESS, systematic resampling, MH sweeps) and
5//! carried a weight-model bug: it reweighted by `dβ · f(x)` on top of a
6//! `β·f(x)` factor, double-counting β. This rebuild deletes all of it. The
7//! driver is [`fugue::adaptive_smc_with_kernel`] run against the model's
8//! **untempered** target (`factor(f)`, see
9//! [`EvolutionModel::smc_model`](super::model::EvolutionModel::smc_model)):
10//! fugue supplies β by likelihood-tempering with an adaptive ESS-driven
11//! ladder, applies it exactly once, and returns an unbiased log-evidence
12//! estimate for free.
13//!
14//! Crossover is fugue's [`CrossoverKernel`] — a population-coupled Metropolis
15//! move on the product target — driven by an address mask supplied here
16//! (genome knowledge stays downstream; trace-space mechanics live upstream).
17
18use std::marker::PhantomData;
19
20use fugue::runtime::handler::run;
21use fugue::runtime::interpreters::ScoreGivenTrace;
22use fugue::{
23    adaptive_smc, adaptive_smc_with_kernel, decode_particle, CrossoverKernel, Model, Particle,
24    ResamplingMethod, SMCConfig, Trace,
25};
26use rand::Rng;
27
28use super::likelihood::GenomeLikelihood;
29use super::model::EvolutionModel;
30use super::prior::GenomePrior;
31use crate::fitness::traits::Fitness;
32use crate::genome::trace_genome::{gene_address, TraceGenome};
33
34/// Configuration of the crossover population kernel.
35#[derive(Clone, Debug)]
36pub struct CrossoverConfig {
37    /// Number of (pair, swap) proposals per sweep.
38    pub n_pairs: usize,
39    /// Per-address probability that a site joins the swap mask.
40    pub swap_probability: f64,
41}
42
43impl Default for CrossoverConfig {
44    fn default() -> Self {
45        Self {
46            n_pairs: 32,
47            swap_probability: 0.5,
48        }
49    }
50}
51
52/// Configuration for [`EvolutionSMC::run`].
53pub struct EvoSmcConfig {
54    /// Number of particles.
55    pub num_particles: usize,
56    /// ESS threshold fraction driving both the adaptive β ladder and
57    /// resampling (fugue `SMCConfig::ess_threshold`).
58    pub ess_threshold: f64,
59    /// Resampling algorithm.
60    pub resampling: ResamplingMethod,
61    /// Per-particle MH rejuvenation sweeps per tempering step.
62    pub rejuvenation_steps: usize,
63    /// Population crossover kernel; `None` = per-particle rejuvenation only.
64    pub crossover: Option<CrossoverConfig>,
65}
66
67impl Default for EvoSmcConfig {
68    fn default() -> Self {
69        Self {
70            num_particles: 500,
71            ess_threshold: 0.5,
72            resampling: ResamplingMethod::Systematic,
73            rejuvenation_steps: 3,
74            crossover: Some(CrossoverConfig::default()),
75        }
76    }
77}
78
79/// The result of a tempered-SMC evolution run: fugue particles (traces +
80/// normalized weights) approximating the Boltzmann posterior `π ∝ p·exp(f)`,
81/// plus the log-evidence estimate.
82///
83/// Genomes are not cached on particles; they are recovered by **decode-replay**
84/// (replaying the particle's trace through the prior/target program, whose
85/// return value *is* the decoded genome).
86pub struct EvolutionPosterior<G: TraceGenome> {
87    /// Final weighted particle population (fugue particles).
88    pub particles: Vec<Particle>,
89    /// Unbiased estimate of the log normalizing constant
90    /// `log Σ_x p(x)·exp(f(x))` — the Bayesian model score.
91    pub log_evidence: f64,
92    _g: PhantomData<G>,
93}
94
95impl<G: TraceGenome> EvolutionPosterior<G> {
96    /// Recover the genome of one particle by replaying its trace.
97    pub fn genome(&self, particle: &Particle, model_fn: &impl Fn() -> Model<G>) -> G {
98        decode_particle(particle, model_fn)
99    }
100
101    /// Decode the whole population as `(genome, normalized_weight)` pairs.
102    pub fn genomes(&self, model_fn: &impl Fn() -> Model<G>) -> Vec<(G, f64)> {
103        self.particles
104            .iter()
105            .map(|p| (decode_particle(p, model_fn), p.weight))
106            .collect()
107    }
108
109    /// Self-normalised weighted posterior mean of coordinate `gene#coord`.
110    pub fn weighted_mean(&self, coord: usize) -> f64 {
111        let addr = gene_address(G::trace_prefix(), coord);
112        let mut total_w = 0.0;
113        let mut mean = 0.0;
114        for p in &self.particles {
115            if let Some(x) = p.trace.get_f64(&addr) {
116                mean += p.weight * x;
117                total_w += p.weight;
118            }
119        }
120        if total_w > 0.0 {
121            mean / total_w
122        } else {
123            0.0
124        }
125    }
126
127    /// Self-normalised weighted posterior variance of coordinate `gene#coord`.
128    pub fn weighted_variance(&self, coord: usize) -> f64 {
129        let addr = gene_address(G::trace_prefix(), coord);
130        let mean = self.weighted_mean(coord);
131        let mut total_w = 0.0;
132        let mut var = 0.0;
133        for p in &self.particles {
134            if let Some(x) = p.trace.get_f64(&addr) {
135                var += p.weight * (x - mean).powi(2);
136                total_w += p.weight;
137            }
138        }
139        if total_w > 0.0 {
140            var / total_w
141        } else {
142            0.0
143        }
144    }
145
146    /// The decoded genome with the highest fitness, and that fitness —
147    /// the optimizer-mode readout for benchmarking against the classic layer.
148    pub fn best<F>(&self, fitness: &F, model_fn: &impl Fn() -> Model<G>) -> Option<(G, f64)>
149    where
150        F: Fitness<Genome = G, Value = f64>,
151    {
152        self.particles
153            .iter()
154            .map(|p| {
155                let g = decode_particle(p, model_fn);
156                let f = fitness.evaluate(&g);
157                (g, f)
158            })
159            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
160    }
161}
162
163/// The tempered-SMC evolution driver.
164pub struct EvolutionSMC;
165
166impl EvolutionSMC {
167    /// Run tempered SMC targeting the Boltzmann posterior
168    /// `π ∝ p(x)·exp(f(x))` of `model` (β is supplied by fugue's adaptive
169    /// tempering; `model`'s own β setting is ignored here by construction).
170    pub fn run<P, L, R>(
171        rng: &mut R,
172        model: &EvolutionModel<P, L>,
173        cfg: EvoSmcConfig,
174    ) -> EvolutionPosterior<P::Genome>
175    where
176        P: GenomePrior,
177        L: GenomeLikelihood<P::Genome>,
178        R: Rng,
179    {
180        let model_fn = model.smc_model();
181        let smc_cfg = SMCConfig {
182            resampling_method: cfg.resampling,
183            ess_threshold: cfg.ess_threshold,
184            rejuvenation_steps: cfg.rejuvenation_steps,
185        };
186        let result = match cfg.crossover {
187            None => adaptive_smc(rng, cfg.num_particles, &model_fn, smc_cfg),
188            Some(xcfg) => {
189                let p_swap = xcfg.swap_probability.clamp(0.0, 1.0);
190                let mut kernel = CrossoverKernel {
191                    n_pairs: xcfg.n_pairs,
192                    // Value-independent, pair-symmetric mask: each address of
193                    // the first parent joins the swap independently.
194                    mask: Box::new(move |a: &Trace, _b: &Trace, rng: &mut dyn rand::RngCore| {
195                        a.choices
196                            .keys()
197                            .filter(|_| rand::Rng::gen::<f64>(rng) < p_swap)
198                            .cloned()
199                            .collect()
200                    }),
201                };
202                adaptive_smc_with_kernel(rng, cfg.num_particles, &model_fn, smc_cfg, &mut kernel)
203            }
204        };
205        EvolutionPosterior {
206            particles: result.particles,
207            log_evidence: result.log_evidence,
208            _g: PhantomData,
209        }
210    }
211}
212
213impl EvolutionSMC {
214    /// Like [`EvolutionSMC::run`], but with an explicit population kernel
215    /// (e.g. a [`CrossoverKernel`] with a
216    /// [`subtree_crossover_mask`](super::grammar::subtree_crossover_mask) for
217    /// grammar-driven tree genomes). `cfg.crossover` is ignored.
218    pub fn run_with_kernel<P, L, R, K>(
219        rng: &mut R,
220        model: &EvolutionModel<P, L>,
221        cfg: EvoSmcConfig,
222        kernel: &mut K,
223    ) -> EvolutionPosterior<P::Genome>
224    where
225        P: GenomePrior,
226        L: GenomeLikelihood<P::Genome>,
227        R: Rng,
228        K: fugue::PopulationKernel<P::Genome>,
229    {
230        let model_fn = model.smc_model();
231        let smc_cfg = SMCConfig {
232            resampling_method: cfg.resampling,
233            ess_threshold: cfg.ess_threshold,
234            rejuvenation_steps: cfg.rejuvenation_steps,
235        };
236        let result = adaptive_smc_with_kernel(rng, cfg.num_particles, &model_fn, smc_cfg, kernel);
237        EvolutionPosterior {
238            particles: result.particles,
239            log_evidence: result.log_evidence,
240            _g: PhantomData,
241        }
242    }
243}
244
245impl EvolutionSMC {
246    /// **Optimizer mode**: run tempered SMC to the posterior (β = 1), then
247    /// keep annealing the ladder toward `beta_max`, concentrating the
248    /// population on the maximizers of the likelihood/fitness.
249    ///
250    /// The continuation is built from fugue's exported primitives and keeps
251    /// every invariant of the tempering loop: at each rung the particles are
252    /// incrementally reweighted by `Δβ·(log_likelihood + log_factors)`,
253    /// normalized, systematically resampled to uniform weights, and
254    /// rejuvenated with π_β-invariant MH (plus the crossover kernel when
255    /// `cfg.crossover` is set). The rung schedule is geometric from 1 to
256    /// `beta_max` over `anneal_steps` rungs.
257    ///
258    /// The returned population approximates `π_{β_max} ∝ p(x)·L(x)^{β_max}`,
259    /// which for large `beta_max` concentrates on the optima — a principled,
260    /// uncertainty-aware replacement for a classic GA on single-objective
261    /// problems. `log_evidence` reflects only the β ≤ 1 ladder (evidence is
262    /// defined at the posterior).
263    pub fn anneal<P, L, R>(
264        rng: &mut R,
265        model: &EvolutionModel<P, L>,
266        cfg: EvoSmcConfig,
267        beta_max: f64,
268        anneal_steps: usize,
269    ) -> EvolutionPosterior<P::Genome>
270    where
271        P: GenomePrior,
272        L: GenomeLikelihood<P::Genome>,
273        R: Rng,
274    {
275        use fugue::{normalize_particles, rejuvenate_particles, resample_particles};
276
277        let crossover = cfg.crossover.clone();
278        let rejuvenation_steps = cfg.rejuvenation_steps;
279        let resampling = cfg.resampling;
280        let mut result = Self::run(rng, model, cfg);
281        if beta_max <= 1.0 || anneal_steps == 0 {
282            return result;
283        }
284
285        let model_fn = model.smc_model();
286        let loglik = |t: &Trace| t.log_likelihood + t.log_factors;
287        let mut kernel = crossover.map(|xcfg| {
288            let p_swap = xcfg.swap_probability.clamp(0.0, 1.0);
289            CrossoverKernel {
290                n_pairs: xcfg.n_pairs,
291                mask: Box::new(move |a: &Trace, _b: &Trace, rng: &mut dyn rand::RngCore| {
292                    a.choices
293                        .keys()
294                        .filter(|_| rand::Rng::gen::<f64>(rng) < p_swap)
295                        .cloned()
296                        .collect()
297                }),
298            }
299        });
300
301        let ln_bmax = beta_max.ln();
302        let mut prev_beta = 1.0;
303        for i in 1..=anneal_steps {
304            let beta = (ln_bmax * i as f64 / anneal_steps as f64).exp();
305            let d_beta = beta - prev_beta;
306
307            // (1) incremental reweight by the tempered increment.
308            for p in &mut result.particles {
309                p.log_weight += d_beta * loglik(&p.trace);
310            }
311            normalize_particles(&mut result.particles);
312
313            // (2) resample to uniform weights.
314            result.particles = resample_particles(rng, &result.particles, resampling);
315
316            // (3) π_β-invariant rejuvenation (+ optional crossover sweep).
317            rejuvenate_particles(
318                rng,
319                &mut result.particles,
320                &model_fn,
321                beta,
322                rejuvenation_steps,
323            );
324            if let Some(k) = kernel.as_mut() {
325                fugue::PopulationKernel::<P::Genome>::sweep(
326                    k,
327                    rng as &mut dyn rand::RngCore,
328                    &mut result.particles,
329                    &model_fn,
330                    beta,
331                );
332            }
333            prev_beta = beta;
334        }
335        normalize_particles(&mut result.particles);
336        result
337    }
338}
339
340/// Score a genome's canonical trace under an arbitrary model — convenience
341/// used by readouts and tests.
342pub fn score_genome<G: TraceGenome, A>(genome: &G, model: Model<A>) -> (A, Trace) {
343    run(
344        ScoreGivenTrace {
345            base: genome.to_trace(),
346            trace: Trace::default(),
347        },
348        model,
349    )
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::genome::bounds::{Bounds, MultiBounds};
356    use crate::genome::real_vector::RealVector;
357    use crate::genome::traits::RealValuedGenome;
358    use crate::inference::model::tests::PtrFitness;
359    use crate::inference::prior::GaussianPrior;
360    use rand::rngs::StdRng;
361    use rand::SeedableRng;
362
363    fn quad_k1_c3(g: &RealVector) -> f64 {
364        -0.5 * g.genes().iter().map(|x| (x - 3.0).powi(2)).sum::<f64>()
365    }
366
367    /// Regression: EV-16 — tempered SMC on a quadratic fitness with a Gaussian
368    /// prior reproduces the conjugate Boltzmann posterior.
369    ///
370    /// Prior N(0, σ0²=4) ⇒ τ0 = 0.25; fitness −0.5(x−3)² ⇒ k = 1, c = 3.
371    /// Posterior at β=1: τ = 1.25, mean = 3/1.25 = 2.4, variance = 0.8.
372    /// Re-driven through the fugue-backed rebuild — this directly exercises
373    /// the β-single-counting fix (fitness enters as `factor(f)`; β only from
374    /// tempering).
375    #[test]
376    fn test_smc_matches_gaussian_conjugate_posterior() {
377        let prior = GaussianPrior::new(0.0, 2.0, 1);
378        let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
379        let mut rng = StdRng::seed_from_u64(42);
380        let result = EvolutionSMC::run(
381            &mut rng,
382            &model,
383            EvoSmcConfig {
384                num_particles: 4000,
385                ess_threshold: 0.5,
386                resampling: ResamplingMethod::Systematic,
387                rejuvenation_steps: 6,
388                crossover: None,
389            },
390        );
391
392        let mean = result.weighted_mean(0);
393        let var = result.weighted_variance(0);
394        assert!(
395            (mean - 2.4).abs() < 0.15,
396            "posterior mean {} vs analytic 2.4",
397            mean
398        );
399        assert!(
400            (var - 0.8).abs() < 0.2,
401            "posterior variance {} vs analytic 0.8",
402            var
403        );
404
405        // Weights are self-normalised.
406        let total: f64 = result.particles.iter().map(|p| p.weight).sum();
407        assert!((total - 1.0).abs() < 1e-6);
408
409        // Analytic evidence check comes for free from the rebuild:
410        // Z = ∫ N(x; 0, 4)·e^{-(x-3)²/2} dx = √(2π·0.8)/√(2π·4) · e^{-9/(2·5)}
411        let analytic_log_z = 0.5 * ((0.8f64).ln() - (4.0f64).ln()) - 9.0 / (2.0 * 5.0);
412        assert!(
413            (result.log_evidence - analytic_log_z).abs() < 0.25,
414            "log evidence {} vs analytic {}",
415            result.log_evidence,
416            analytic_log_z
417        );
418    }
419
420    /// Same conjugate target, with the crossover population kernel enabled —
421    /// the kernel must not bias the posterior (product-target invariance) nor
422    /// the evidence (FG-58).
423    #[test]
424    fn test_smc_with_crossover_matches_conjugate_posterior() {
425        let prior = GaussianPrior::new(0.0, 2.0, 2);
426        // Independent per-coordinate quadratic pull toward 3.
427        let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
428        let mut rng = StdRng::seed_from_u64(1234);
429        let result = EvolutionSMC::run(
430            &mut rng,
431            &model,
432            EvoSmcConfig {
433                num_particles: 3000,
434                ess_threshold: 0.5,
435                resampling: ResamplingMethod::Systematic,
436                rejuvenation_steps: 4,
437                crossover: Some(CrossoverConfig {
438                    n_pairs: 500,
439                    swap_probability: 0.5,
440                }),
441            },
442        );
443        for coord in 0..2 {
444            let mean = result.weighted_mean(coord);
445            let var = result.weighted_variance(coord);
446            assert!(
447                (mean - 2.4).abs() < 0.15,
448                "coord {} posterior mean {} vs 2.4",
449                coord,
450                mean
451            );
452            assert!(
453                (var - 0.8).abs() < 0.25,
454                "coord {} posterior variance {} vs 0.8",
455                coord,
456                var
457            );
458        }
459    }
460
461    /// The rebuilt result exposes decode-replay: recover genomes from bare
462    /// particle traces via the prior program's return value.
463    #[test]
464    fn test_decode_replay_recovers_genomes() {
465        let prior = GaussianPrior::new(0.0, 2.0, 1);
466        let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
467        let mut rng = StdRng::seed_from_u64(5);
468        let result = EvolutionSMC::run(
469            &mut rng,
470            &model,
471            EvoSmcConfig {
472                num_particles: 100,
473                rejuvenation_steps: 2,
474                crossover: None,
475                ..Default::default()
476            },
477        );
478        let model_fn = model.smc_model();
479        let decoded = result.genomes(&model_fn);
480        assert_eq!(decoded.len(), 100);
481        for (g, _w) in &decoded {
482            assert_eq!(g.genes().len(), 1);
483        }
484        let (best, best_f) = result.best(&PtrFitness(quad_k1_c3), &model_fn).unwrap();
485        assert!(best_f.is_finite());
486        assert!((quad_k1_c3(&best) - best_f).abs() < 1e-12);
487    }
488
489    /// Optimizer mode: annealing past β = 1 concentrates the population on
490    /// the fitness optimum far beyond the posterior's spread.
491    #[test]
492    fn test_anneal_concentrates_on_optimum() {
493        // Fitness -0.5·Σx², prior N(0, 2²): posterior sd ≈ 0.89; at β = 200
494        // the tempered target's sd ≈ 0.07.
495        let prior = GaussianPrior::new(0.0, 2.0, 2);
496        let model = EvolutionModel::new(prior, PtrFitness(super::tests::quad_origin_local));
497        let mut rng = StdRng::seed_from_u64(31);
498        let cfg = || EvoSmcConfig {
499            num_particles: 400,
500            ess_threshold: 0.5,
501            resampling: ResamplingMethod::Systematic,
502            rejuvenation_steps: 4,
503            crossover: Some(CrossoverConfig::default()),
504        };
505        let posterior = EvolutionSMC::run(&mut rng, &model, cfg());
506        let annealed = EvolutionSMC::anneal(&mut rng, &model, cfg(), 200.0, 12);
507
508        let spread = |r: &EvolutionPosterior<RealVector>| {
509            (r.weighted_variance(0) + r.weighted_variance(1)).sqrt()
510        };
511        assert!(
512            spread(&annealed) < 0.35 * spread(&posterior),
513            "annealed spread {} should be far below posterior spread {}",
514            spread(&annealed),
515            spread(&posterior)
516        );
517
518        let model_fn = model.smc_model();
519        let (best, best_f) = annealed
520            .best(&PtrFitness(super::tests::quad_origin_local), &model_fn)
521            .unwrap();
522        assert!(
523            best_f > -0.02,
524            "annealed best fitness {} (genome {:?}) not near optimum 0",
525            best_f,
526            best.genes()
527        );
528    }
529
530    pub(super) fn quad_origin_local(g: &RealVector) -> f64 {
531        -0.5 * g.genes().iter().map(|x| x * x).sum::<f64>()
532    }
533
534    /// Bounds are respected end-to-end: with a uniform-box prior every
535    /// particle stays inside the box (out-of-box scores −∞ and can never
536    /// survive).
537    #[test]
538    fn test_smc_respects_bounds() {
539        use crate::inference::prior::UniformBoxPrior;
540        let prior = UniformBoxPrior::new(MultiBounds::new(vec![Bounds::new(-2.0, 2.0)]));
541        let model = EvolutionModel::new(prior, PtrFitness(|g: &RealVector| g.genes()[0]));
542        let mut rng = StdRng::seed_from_u64(9);
543        let result = EvolutionSMC::run(
544            &mut rng,
545            &model,
546            EvoSmcConfig {
547                num_particles: 300,
548                rejuvenation_steps: 3,
549                crossover: Some(CrossoverConfig::default()),
550                ..Default::default()
551            },
552        );
553        for p in &result.particles {
554            let x = p.trace.get_f64(&fugue::addr!("gene", 0)).unwrap();
555            assert!((-2.0..=2.0).contains(&x), "particle escaped bounds: {}", x);
556        }
557    }
558}