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 [`fugue::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_with_kernel, decode_particle, score_given_trace_reconciled, Address, Model,
24    NoKernel, Particle, PopulationKernel, ResamplingMethod, SMCConfig, Trace,
25};
26use rand::Rng;
27
28use super::grammar::CrossoverMaskFn;
29use super::likelihood::GenomeLikelihood;
30use super::model::EvolutionModel;
31use super::prior::GenomePrior;
32use crate::fitness::traits::Fitness;
33use crate::genome::trace_genome::{gene_address, TraceGenome};
34
35/// Configuration of the generic crossover population kernel
36/// ([`SharedSiteCrossover`]) that [`EvolutionSMC::run`] and
37/// [`EvolutionSMC::anneal`] build from [`EvoSmcConfig::crossover`].
38///
39/// The kernel swaps a random subset of the addresses **shared by both
40/// parents** (same address, same value type; each joins the swap
41/// independently with probability `swap_probability`) and re-scores both
42/// children strictly, rejecting any proposal whose children are not complete
43/// executions over their own pre-swap address sets. That makes it safe on
44/// every prior, including variable-structure ones (grammar trees,
45/// variable-length genomes): it cannot panic and it never accepts a
46/// structurally inconsistent child. The price is that on a variable-structure
47/// prior only *structure-preserving* swaps are ever accepted — constants,
48/// variable indices, same-arity function choices — because exchanging a
49/// structural site (a `#leaf` flag, an arity-changing `#func`) opens a branch
50/// the child has no choices for. Subtree grafts need the model-aware
51/// [`subtree_crossover_mask`](super::grammar::subtree_crossover_mask) driven
52/// through [`EvolutionSMC::run_with_kernel`] /
53/// [`EvolutionSMC::anneal_with_kernel`]. On a fixed-structure prior (every
54/// vector prior in [`super::prior`]) the shared set is the whole address set
55/// and the kernel is exactly fugue's [`fugue::CrossoverKernel`].
56#[derive(Clone, Debug)]
57pub struct CrossoverConfig {
58    /// Number of (pair, swap) proposals per sweep.
59    pub n_pairs: usize,
60    /// Per-address probability that a shared site joins the swap mask.
61    pub swap_probability: f64,
62}
63
64/// The address set a [`SharedSiteCrossover`] proposal may exchange: every
65/// address present in **both** traces with the same value type, each kept
66/// independently with probability `p_swap`.
67///
68/// Value-independent (it reads only addresses and value *types*) and
69/// symmetric in its two arguments (the shared set is a set intersection,
70/// iterated in `BTreeMap` order, so the coin sequence is identical for
71/// `(a, b)` and `(b, a)`), which is the mask contract of
72/// [`fugue::CrossoverKernel`]. Exposed for callers who build fugue's kernel
73/// directly for a fixed-structure model; [`SharedSiteCrossover`] adds the
74/// strict re-score that variable-structure models need on top of it.
75pub fn shared_site_crossover_mask(p_swap: f64) -> CrossoverMaskFn {
76    let p_swap = p_swap.clamp(0.0, 1.0);
77    Box::new(move |a: &Trace, b: &Trace, rng: &mut dyn rand::RngCore| {
78        shared_site_mask(a, b, p_swap, rng)
79    })
80}
81
82fn shared_site_mask(
83    a: &Trace,
84    b: &Trace,
85    p_swap: f64,
86    rng: &mut dyn rand::RngCore,
87) -> Vec<Address> {
88    a.choices
89        .iter()
90        .filter(|(addr, ca)| {
91            b.choices
92                .get(*addr)
93                .is_some_and(|cb| cb.value.type_name() == ca.value.type_name())
94        })
95        .filter(|_| rand::Rng::gen::<f64>(rng) < p_swap)
96        .map(|(addr, _)| addr.clone())
97        .collect()
98}
99
100/// Exchange the choices at `swap` between `a` and `b` (pure choice surgery;
101/// the children's accumulators are not valid until re-scored).
102fn swap_block(a: &Trace, b: &Trace, swap: &[Address]) -> (Trace, Trace) {
103    let mut ca = a.clone();
104    let mut cb = b.clone();
105    for addr in swap {
106        let from_a = ca.choices.remove(addr);
107        let from_b = cb.choices.remove(addr);
108        if let Some(c) = from_b {
109            ca.choices.insert(addr.clone(), c);
110        }
111        if let Some(c) = from_a {
112            cb.choices.insert(addr.clone(), c);
113        }
114    }
115    (ca, cb)
116}
117
118/// Re-score `base` and accept it only as a **complete execution over exactly
119/// its own address set**: the model must visit every address of `base` (with
120/// the base's value types) and no others. `None` otherwise.
121///
122/// Uses fugue's reconciling scorer rather than the strict one: the strict
123/// handler hands the model a `Default::default()` value after recording a
124/// missing address, and a model whose *structure* depends on that value (a
125/// grammar reading a missing `#leaf` as `false` = "function node") recurses
126/// without bound. The reconciling scorer draws the missing site from its
127/// prior instead, so the replay always terminates, and its report tells us
128/// exactly whether the child was complete: no fresh and no vanished sites.
129/// The draws consume `rng` only on proposals that are rejected anyway.
130fn rescore_complete<A>(
131    base: &Trace,
132    rng: &mut dyn rand::RngCore,
133    model_fn: &dyn Fn() -> Model<A>,
134) -> Option<Trace> {
135    let mut rng = &mut *rng;
136    let (_a, scored, report) =
137        score_given_trace_reconciled(base.clone(), &mut rng, model_fn()).ok()?;
138    (report.fresh_addresses.is_empty()
139        && report.vanished_addresses.is_empty()
140        && scored.choices.len() == base.choices.len())
141    .then_some(scored)
142}
143
144/// Structure-safe crossover population kernel: fugue's [`fugue::CrossoverKernel`]
145/// move (pick two distinct particles, exchange the block of choices at a
146/// masked address set, accept the pair with the product-target Metropolis
147/// ratio) with the mask of [`shared_site_crossover_mask`] and a
148/// **completeness-checked** re-score (fugue's reconciling scorer plus its
149/// fresh/vanished report) in place of fugue's panicking one.
150///
151/// A proposal is rejected outright when either child fails to re-score as a
152/// complete execution over its own pre-swap address set — the model visited
153/// an address the child does not hold (a structural site whose new value
154/// opened a branch), or left some of the child's choices unvisited (a branch
155/// closed). Every accepted pair therefore has exactly the parents' address
156/// sets, so the swap is an involution on the state space and the mask
157/// distribution is the same in both directions: the move is symmetric and
158/// leaves the product of tempered targets invariant; the rejected proposals
159/// are self-loops, which detailed balance ignores. This is what
160/// [`EvoSmcConfig::crossover`] builds, so `EvolutionSMC::run` / `anneal`
161/// with `EvoSmcConfig::default()` are safe on any [`GenomePrior`].
162#[derive(Clone, Debug)]
163pub struct SharedSiteCrossover {
164    /// Number of (pair, swap) proposals per sweep.
165    pub n_pairs: usize,
166    /// Per-address probability that a shared site joins the swap mask.
167    pub swap_probability: f64,
168}
169
170impl From<&CrossoverConfig> for SharedSiteCrossover {
171    fn from(cfg: &CrossoverConfig) -> Self {
172        Self {
173            n_pairs: cfg.n_pairs,
174            swap_probability: cfg.swap_probability.clamp(0.0, 1.0),
175        }
176    }
177}
178
179impl<A> PopulationKernel<A> for SharedSiteCrossover {
180    fn sweep(
181        &mut self,
182        rng: &mut dyn rand::RngCore,
183        particles: &mut [Particle],
184        model_fn: &dyn Fn() -> Model<A>,
185        beta: f64,
186    ) {
187        let n = particles.len();
188        if n < 2 {
189            return;
190        }
191        for _ in 0..self.n_pairs {
192            let i = rng.gen_range(0..n);
193            let mut j = rng.gen_range(0..n - 1);
194            if j >= i {
195                j += 1; // distinct partner
196            }
197            let s = shared_site_mask(
198                &particles[i].trace,
199                &particles[j].trace,
200                self.swap_probability,
201                rng,
202            );
203            if s.is_empty() {
204                continue;
205            }
206            let (ti, tj) = swap_block(&particles[i].trace, &particles[j].trace, &s);
207            let (Some(ci), Some(cj)) = (
208                rescore_complete(&ti, rng, model_fn),
209                rescore_complete(&tj, rng, model_fn),
210            ) else {
211                continue; // structurally inconsistent child: self-loop
212            };
213            // Tempered log-density of one execution:
214            //   log π_β(θ) = log_prior + β·(log_likelihood + log_factors).
215            let logd = |t: &Trace| t.log_prior + beta * (t.log_likelihood + t.log_factors);
216            let log_alpha =
217                (logd(&ci) + logd(&cj)) - (logd(&particles[i].trace) + logd(&particles[j].trace));
218            if log_alpha >= 0.0 || rng.gen::<f64>() < log_alpha.exp() {
219                particles[i].trace = ci; // only traces move;
220                particles[j].trace = cj; // weights untouched (kernel contract).
221            }
222        }
223    }
224}
225
226impl Default for CrossoverConfig {
227    fn default() -> Self {
228        Self {
229            n_pairs: 32,
230            swap_probability: 0.5,
231        }
232    }
233}
234
235/// Configuration for [`EvolutionSMC::run`].
236pub struct EvoSmcConfig {
237    /// Number of particles.
238    pub num_particles: usize,
239    /// ESS threshold fraction driving both the adaptive β ladder and
240    /// resampling (fugue `SMCConfig::ess_threshold`).
241    pub ess_threshold: f64,
242    /// Resampling algorithm.
243    pub resampling: ResamplingMethod,
244    /// Per-particle MH rejuvenation sweeps per tempering step.
245    pub rejuvenation_steps: usize,
246    /// Population crossover kernel; `None` = per-particle rejuvenation only.
247    pub crossover: Option<CrossoverConfig>,
248}
249
250impl Default for EvoSmcConfig {
251    fn default() -> Self {
252        Self {
253            num_particles: 500,
254            ess_threshold: 0.5,
255            resampling: ResamplingMethod::Systematic,
256            rejuvenation_steps: 3,
257            crossover: Some(CrossoverConfig::default()),
258        }
259    }
260}
261
262/// The result of a tempered-SMC evolution run: fugue particles (traces +
263/// normalized weights) approximating the Boltzmann posterior `π ∝ p·exp(f)`,
264/// plus the log-evidence estimate.
265///
266/// Genomes are not cached on particles; they are recovered by **decode-replay**
267/// (replaying the particle's trace through the prior/target program, whose
268/// return value *is* the decoded genome).
269pub struct EvolutionPosterior<G: TraceGenome> {
270    /// Final weighted particle population (fugue particles).
271    pub particles: Vec<Particle>,
272    /// Unbiased estimate of the log normalizing constant
273    /// `log Σ_x p(x)·exp(f(x))` — the Bayesian model score.
274    pub log_evidence: f64,
275    _g: PhantomData<G>,
276}
277
278impl<G: TraceGenome> EvolutionPosterior<G> {
279    /// Recover the genome of one particle by replaying its trace.
280    pub fn genome(&self, particle: &Particle, model_fn: &impl Fn() -> Model<G>) -> G {
281        decode_particle(particle, model_fn)
282    }
283
284    /// Decode the whole population as `(genome, normalized_weight)` pairs.
285    pub fn genomes(&self, model_fn: &impl Fn() -> Model<G>) -> Vec<(G, f64)> {
286        self.particles
287            .iter()
288            .map(|p| (decode_particle(p, model_fn), p.weight))
289            .collect()
290    }
291
292    /// Self-normalised weighted posterior mean of the real coordinate at the
293    /// canonical address `<prefix>#coord` (`gene#coord` for `RealVector`).
294    ///
295    /// `None` when no particle with positive weight carries that site — a
296    /// tree genome (whose grammar addresses are `node/…#const`, not
297    /// `gene#i`), a coordinate beyond the genome's dimension, or an empty
298    /// population — instead of a silent `0.0` (EV-N5). Non-real genomes have
299    /// no coordinate mean; decode them with [`Self::genomes`].
300    pub fn weighted_mean(&self, coord: usize) -> Option<f64> {
301        let addr = gene_address(G::trace_prefix(), coord);
302        let mut total_w = 0.0;
303        let mut mean = 0.0;
304        for p in &self.particles {
305            if let Some(x) = p.trace.get_f64(&addr) {
306                mean += p.weight * x;
307                total_w += p.weight;
308            }
309        }
310        (total_w > 0.0).then(|| mean / total_w)
311    }
312
313    /// Self-normalised weighted posterior variance of the real coordinate at
314    /// `<prefix>#coord`; `None` under the same conditions as
315    /// [`Self::weighted_mean`].
316    pub fn weighted_variance(&self, coord: usize) -> Option<f64> {
317        let addr = gene_address(G::trace_prefix(), coord);
318        let mean = self.weighted_mean(coord)?;
319        let mut total_w = 0.0;
320        let mut var = 0.0;
321        for p in &self.particles {
322            if let Some(x) = p.trace.get_f64(&addr) {
323                var += p.weight * (x - mean).powi(2);
324                total_w += p.weight;
325            }
326        }
327        (total_w > 0.0).then(|| var / total_w)
328    }
329
330    /// The decoded genome with the highest fitness, and that fitness —
331    /// the optimizer-mode readout for benchmarking against the classic layer.
332    pub fn best<F>(&self, fitness: &F, model_fn: &impl Fn() -> Model<G>) -> Option<(G, f64)>
333    where
334        F: Fitness<Genome = G, Value = f64>,
335    {
336        self.particles
337            .iter()
338            .map(|p| {
339                let g = decode_particle(p, model_fn);
340                let f = fitness.evaluate(&g);
341                (g, f)
342            })
343            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
344    }
345}
346
347/// The tempered-SMC evolution driver.
348pub struct EvolutionSMC;
349
350impl EvolutionSMC {
351    /// Run tempered SMC targeting the Boltzmann posterior
352    /// `π ∝ p(x)·exp(f(x))` of `model` (β is supplied by fugue's adaptive
353    /// tempering; `model`'s own β setting is ignored here by construction).
354    ///
355    /// `cfg.crossover` builds a structure-safe [`SharedSiteCrossover`] kernel
356    /// (see [`CrossoverConfig`] for what it can and cannot exchange on a
357    /// variable-structure prior); `None` runs per-particle rejuvenation only.
358    pub fn run<P, L, R>(
359        rng: &mut R,
360        model: &EvolutionModel<P, L>,
361        cfg: EvoSmcConfig,
362    ) -> EvolutionPosterior<P::Genome>
363    where
364        P: GenomePrior,
365        L: GenomeLikelihood<P::Genome>,
366        R: Rng,
367    {
368        match cfg.crossover.as_ref().map(SharedSiteCrossover::from) {
369            None => Self::run_with_kernel(rng, model, cfg, &mut NoKernel),
370            Some(mut kernel) => Self::run_with_kernel(rng, model, cfg, &mut kernel),
371        }
372    }
373}
374
375impl EvolutionSMC {
376    /// Like [`EvolutionSMC::run`], but with an explicit population kernel
377    /// (e.g. a [`fugue::CrossoverKernel`] with a
378    /// [`subtree_crossover_mask`](super::grammar::subtree_crossover_mask) for
379    /// grammar-driven tree genomes). `cfg.crossover` is ignored.
380    pub fn run_with_kernel<P, L, R, K>(
381        rng: &mut R,
382        model: &EvolutionModel<P, L>,
383        cfg: EvoSmcConfig,
384        kernel: &mut K,
385    ) -> EvolutionPosterior<P::Genome>
386    where
387        P: GenomePrior,
388        L: GenomeLikelihood<P::Genome>,
389        R: Rng,
390        K: fugue::PopulationKernel<P::Genome>,
391    {
392        let model_fn = model.smc_model();
393        let smc_cfg = SMCConfig {
394            resampling_method: cfg.resampling,
395            ess_threshold: cfg.ess_threshold,
396            rejuvenation_steps: cfg.rejuvenation_steps,
397        };
398        let result = adaptive_smc_with_kernel(rng, cfg.num_particles, &model_fn, smc_cfg, kernel);
399        EvolutionPosterior {
400            particles: result.particles,
401            log_evidence: result.log_evidence,
402            _g: PhantomData,
403        }
404    }
405}
406
407impl EvolutionSMC {
408    /// **Optimizer mode**: run tempered SMC to the posterior (β = 1), then
409    /// keep annealing the ladder toward `beta_max`, concentrating the
410    /// population on the maximizers of the likelihood/fitness.
411    ///
412    /// The continuation is built from fugue's exported primitives and keeps
413    /// every invariant of the tempering loop: at each rung the particles are
414    /// incrementally reweighted by `Δβ·(log_likelihood + log_factors)`,
415    /// normalized, systematically resampled to uniform weights, and
416    /// rejuvenated with π_β-invariant MH (plus the structure-safe
417    /// [`SharedSiteCrossover`] sweep when `cfg.crossover` is set — see
418    /// [`CrossoverConfig`]; for a model-aware kernel such as
419    /// [`subtree_crossover_mask`](super::grammar::subtree_crossover_mask) use
420    /// [`EvolutionSMC::anneal_with_kernel`]). The rung schedule is geometric
421    /// from 1 to `beta_max` over `anneal_steps` rungs.
422    ///
423    /// The returned population approximates `π_{β_max} ∝ p(x)·L(x)^{β_max}`,
424    /// which for large `beta_max` concentrates on the optima — a principled,
425    /// uncertainty-aware replacement for a classic GA on single-objective
426    /// problems. `log_evidence` reflects only the β ≤ 1 ladder (evidence is
427    /// defined at the posterior).
428    ///
429    /// With `rejuvenation_steps == 0` and no kernel nothing moves a particle
430    /// past β = 1: each rung only reweights and resamples, so the population
431    /// collapses onto duplicates of the fittest posterior particles. Keep at
432    /// least one rejuvenation step (or a kernel) when annealing.
433    pub fn anneal<P, L, R>(
434        rng: &mut R,
435        model: &EvolutionModel<P, L>,
436        cfg: EvoSmcConfig,
437        beta_max: f64,
438        anneal_steps: usize,
439    ) -> EvolutionPosterior<P::Genome>
440    where
441        P: GenomePrior,
442        L: GenomeLikelihood<P::Genome>,
443        R: Rng,
444    {
445        match cfg.crossover.as_ref().map(SharedSiteCrossover::from) {
446            None => {
447                Self::anneal_with_kernel(rng, model, cfg, beta_max, anneal_steps, &mut NoKernel)
448            }
449            Some(mut kernel) => {
450                Self::anneal_with_kernel(rng, model, cfg, beta_max, anneal_steps, &mut kernel)
451            }
452        }
453    }
454
455    /// Like [`EvolutionSMC::anneal`], but with an explicit population kernel
456    /// applied both inside the β ≤ 1 ladder and at every annealing rung —
457    /// the optimizer-mode counterpart of [`EvolutionSMC::run_with_kernel`]
458    /// (e.g. a [`fugue::CrossoverKernel`] with a
459    /// [`subtree_crossover_mask`](super::grammar::subtree_crossover_mask) to
460    /// anneal a grammar prior with subtree grafts). `cfg.crossover` is
461    /// ignored; pass [`fugue::NoKernel`] for rejuvenation only.
462    pub fn anneal_with_kernel<P, L, R, K>(
463        rng: &mut R,
464        model: &EvolutionModel<P, L>,
465        cfg: EvoSmcConfig,
466        beta_max: f64,
467        anneal_steps: usize,
468        kernel: &mut K,
469    ) -> EvolutionPosterior<P::Genome>
470    where
471        P: GenomePrior,
472        L: GenomeLikelihood<P::Genome>,
473        R: Rng,
474        K: PopulationKernel<P::Genome>,
475    {
476        use fugue::inference::mcmc_utils::DiminishingAdaptation;
477        use fugue::{adaptive_single_site_mh_cached, normalize_particles, resample_particles};
478        use std::collections::HashMap;
479
480        let rejuvenation_steps = cfg.rejuvenation_steps;
481        let resampling = cfg.resampling;
482        let mut result = Self::run_with_kernel(rng, model, cfg, kernel);
483        if beta_max <= 1.0 || anneal_steps == 0 {
484            return result;
485        }
486
487        let model_fn = model.smc_model();
488        let loglik = |t: &Trace| t.log_likelihood + t.log_factors;
489        // One proposal-scale adaptation for the whole annealing continuation
490        // (EV-N5): fugue's `rejuvenate_particles` starts a fresh
491        // `DiminishingAdaptation` on every call, so each rung re-learned its
492        // scales from the default. The rejuvenation target at rung β is the
493        // model's own fixed-β program (`target_model()` at β), which is
494        // exactly fugue's tempered density `log_prior + β·(log_likelihood +
495        // log_factors)` whenever the likelihood tempers linearly —
496        // `FactorFitness` and `tempered_observe` do.
497        let mut adaptation = DiminishingAdaptation::new(0.44, 0.7);
498        let no_overrides: HashMap<fugue::Address, fugue::SiteProposal> = HashMap::new();
499
500        let ln_bmax = beta_max.ln();
501        let mut prev_beta = 1.0;
502        for i in 1..=anneal_steps {
503            let beta = (ln_bmax * i as f64 / anneal_steps as f64).exp();
504            let d_beta = beta - prev_beta;
505
506            // (1) incremental reweight by the tempered increment.
507            for p in &mut result.particles {
508                p.log_weight += d_beta * loglik(&p.trace);
509            }
510            normalize_particles(&mut result.particles);
511
512            // (2) resample to uniform weights.
513            result.particles = resample_particles(rng, &result.particles, resampling);
514
515            // (3) π_β-invariant rejuvenation (+ optional population kernel).
516            if rejuvenation_steps > 0 {
517                let tempered = model.clone().with_beta(beta);
518                let tempered_fn = tempered.target_model();
519                for p in &mut result.particles {
520                    // Score the particle under the β program once (the cached
521                    // step reads the tempered log-density from the trace) ...
522                    let (_g, mut cur) = run(
523                        ScoreGivenTrace {
524                            base: std::mem::take(&mut p.trace),
525                            trace: Trace::default(),
526                        },
527                        tempered_fn(),
528                    );
529                    for _ in 0..rejuvenation_steps {
530                        if let Some((_g, t, _lw)) = adaptive_single_site_mh_cached(
531                            rng,
532                            &tempered_fn,
533                            &cur,
534                            &mut adaptation,
535                            &no_overrides,
536                            true,
537                        ) {
538                            cur = t;
539                        }
540                    }
541                    // ... and back under the β = 1 program, whose accumulators
542                    // the next rung's reweight and the kernel sweep read.
543                    let (_g, back) = run(
544                        ScoreGivenTrace {
545                            base: cur,
546                            trace: Trace::default(),
547                        },
548                        model_fn(),
549                    );
550                    p.trace = back;
551                }
552            }
553            if !kernel.is_identity() {
554                kernel.sweep(
555                    rng as &mut dyn rand::RngCore,
556                    &mut result.particles,
557                    &model_fn,
558                    beta,
559                );
560            }
561            prev_beta = beta;
562        }
563        normalize_particles(&mut result.particles);
564        result
565    }
566}
567
568/// Score a genome's canonical trace ([`TraceGenome::to_trace`]) under an
569/// arbitrary model — convenience used by readouts and tests. Errors instead of
570/// panicking when the model's address structure does not match the encoding
571/// (EV-N3); see [`EvolutionModel::score`] for the error cases.
572pub fn score_genome<G: TraceGenome, A>(
573    genome: &G,
574    model: Model<A>,
575) -> Result<(A, Trace), crate::error::GenomeError> {
576    super::model::score_complete(genome.to_trace(), model)
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::genome::bounds::{Bounds, MultiBounds};
583    use crate::genome::real_vector::RealVector;
584    use crate::genome::traits::RealValuedGenome;
585    use crate::inference::model::tests::PtrFitness;
586    use crate::inference::prior::GaussianPrior;
587    use rand::rngs::StdRng;
588    use rand::SeedableRng;
589
590    fn quad_k1_c3(g: &RealVector) -> f64 {
591        -0.5 * g.genes().iter().map(|x| (x - 3.0).powi(2)).sum::<f64>()
592    }
593
594    /// Regression: EV-16 — tempered SMC on a quadratic fitness with a Gaussian
595    /// prior reproduces the conjugate Boltzmann posterior.
596    ///
597    /// Prior N(0, σ0²=4) ⇒ τ0 = 0.25; fitness −0.5(x−3)² ⇒ k = 1, c = 3.
598    /// Posterior at β=1: τ = 1.25, mean = 3/1.25 = 2.4, variance = 0.8.
599    /// Re-driven through the fugue-backed rebuild — this directly exercises
600    /// the β-single-counting fix (fitness enters as `factor(f)`; β only from
601    /// tempering).
602    #[test]
603    fn test_smc_matches_gaussian_conjugate_posterior() {
604        let prior = GaussianPrior::new(0.0, 2.0, 1);
605        let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
606        let mut rng = StdRng::seed_from_u64(42);
607        let result = EvolutionSMC::run(
608            &mut rng,
609            &model,
610            EvoSmcConfig {
611                num_particles: 4000,
612                ess_threshold: 0.5,
613                resampling: ResamplingMethod::Systematic,
614                rejuvenation_steps: 6,
615                crossover: None,
616            },
617        );
618
619        let mean = result.weighted_mean(0).expect("real coordinate present");
620        let var = result
621            .weighted_variance(0)
622            .expect("real coordinate present");
623        assert!(
624            (mean - 2.4).abs() < 0.15,
625            "posterior mean {} vs analytic 2.4",
626            mean
627        );
628        assert!(
629            (var - 0.8).abs() < 0.2,
630            "posterior variance {} vs analytic 0.8",
631            var
632        );
633
634        // Weights are self-normalised.
635        let total: f64 = result.particles.iter().map(|p| p.weight).sum();
636        assert!((total - 1.0).abs() < 1e-6);
637
638        // Analytic evidence check comes for free from the rebuild:
639        // Z = ∫ N(x; 0, 4)·e^{-(x-3)²/2} dx = √(2π·0.8)/√(2π·4) · e^{-9/(2·5)}
640        let analytic_log_z = 0.5 * ((0.8f64).ln() - (4.0f64).ln()) - 9.0 / (2.0 * 5.0);
641        assert!(
642            (result.log_evidence - analytic_log_z).abs() < 0.25,
643            "log evidence {} vs analytic {}",
644            result.log_evidence,
645            analytic_log_z
646        );
647    }
648
649    /// Same conjugate target, with the crossover population kernel enabled —
650    /// the kernel must not bias the posterior (product-target invariance) nor
651    /// the evidence (FG-58).
652    #[test]
653    fn test_smc_with_crossover_matches_conjugate_posterior() {
654        let prior = GaussianPrior::new(0.0, 2.0, 2);
655        // Independent per-coordinate quadratic pull toward 3.
656        let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
657        let mut rng = StdRng::seed_from_u64(1234);
658        let result = EvolutionSMC::run(
659            &mut rng,
660            &model,
661            EvoSmcConfig {
662                num_particles: 3000,
663                ess_threshold: 0.5,
664                resampling: ResamplingMethod::Systematic,
665                rejuvenation_steps: 4,
666                crossover: Some(CrossoverConfig {
667                    n_pairs: 500,
668                    swap_probability: 0.5,
669                }),
670            },
671        );
672        for coord in 0..2 {
673            let mean = result
674                .weighted_mean(coord)
675                .expect("real coordinate present");
676            let var = result
677                .weighted_variance(coord)
678                .expect("real coordinate present");
679            assert!(
680                (mean - 2.4).abs() < 0.15,
681                "coord {} posterior mean {} vs 2.4",
682                coord,
683                mean
684            );
685            assert!(
686                (var - 0.8).abs() < 0.25,
687                "coord {} posterior variance {} vs 0.8",
688                coord,
689                var
690            );
691        }
692    }
693
694    /// The rebuilt result exposes decode-replay: recover genomes from bare
695    /// particle traces via the prior program's return value.
696    #[test]
697    fn test_decode_replay_recovers_genomes() {
698        let prior = GaussianPrior::new(0.0, 2.0, 1);
699        let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
700        let mut rng = StdRng::seed_from_u64(5);
701        let result = EvolutionSMC::run(
702            &mut rng,
703            &model,
704            EvoSmcConfig {
705                num_particles: 100,
706                rejuvenation_steps: 2,
707                crossover: None,
708                ..Default::default()
709            },
710        );
711        let model_fn = model.smc_model();
712        let decoded = result.genomes(&model_fn);
713        assert_eq!(decoded.len(), 100);
714        for (g, _w) in &decoded {
715            assert_eq!(g.genes().len(), 1);
716        }
717        let (best, best_f) = result.best(&PtrFitness(quad_k1_c3), &model_fn).unwrap();
718        assert!(best_f.is_finite());
719        assert!((quad_k1_c3(&best) - best_f).abs() < 1e-12);
720    }
721
722    /// Optimizer mode: annealing past β = 1 concentrates the population on
723    /// the fitness optimum far beyond the posterior's spread.
724    #[test]
725    fn test_anneal_concentrates_on_optimum() {
726        // Fitness -0.5·Σx², prior N(0, 2²): posterior sd ≈ 0.89; at β = 200
727        // the tempered target's sd ≈ 0.07.
728        let prior = GaussianPrior::new(0.0, 2.0, 2);
729        let model = EvolutionModel::new(prior, PtrFitness(super::tests::quad_origin_local));
730        let mut rng = StdRng::seed_from_u64(31);
731        let cfg = || EvoSmcConfig {
732            num_particles: 400,
733            ess_threshold: 0.5,
734            resampling: ResamplingMethod::Systematic,
735            rejuvenation_steps: 4,
736            crossover: Some(CrossoverConfig::default()),
737        };
738        let posterior = EvolutionSMC::run(&mut rng, &model, cfg());
739        let annealed = EvolutionSMC::anneal(&mut rng, &model, cfg(), 200.0, 12);
740
741        let spread = |r: &EvolutionPosterior<RealVector>| {
742            (r.weighted_variance(0).expect("real coordinate present")
743                + r.weighted_variance(1).expect("real coordinate present"))
744            .sqrt()
745        };
746        assert!(
747            spread(&annealed) < 0.35 * spread(&posterior),
748            "annealed spread {} should be far below posterior spread {}",
749            spread(&annealed),
750            spread(&posterior)
751        );
752
753        let model_fn = model.smc_model();
754        let (best, best_f) = annealed
755            .best(&PtrFitness(super::tests::quad_origin_local), &model_fn)
756            .unwrap();
757        assert!(
758            best_f > -0.02,
759            "annealed best fitness {} (genome {:?}) not near optimum 0",
760            best_f,
761            best.genes()
762        );
763    }
764
765    /// EV-N1: the generic crossover of `EvoSmcConfig::default()` used to
766    /// panic on any variable-structure prior (the mask was a random subset of
767    /// the first parent's addresses; `swap_block` could move a site absent
768    /// from the partner out of a child, and fugue's `ScoreGivenTrace` re-score
769    /// panics on a missing site). The default config must now run on the
770    /// grammar prior and produce a sane posterior: normalized weights, finite
771    /// evidence, every particle decodes to a valid tree with finite prior
772    /// mass, and the posterior predictive tracks the data.
773    #[test]
774    fn test_default_config_runs_on_grammar_prior() {
775        use crate::inference::grammar::{ArithmeticGrammarPrior, GaussianRegression, NoiseSpec};
776        let xs: Vec<f64> = (-10..=10).map(|i| i as f64 / 5.0).collect();
777        let ys: Vec<f64> = xs.iter().map(|x| x + 1.0).collect();
778        let prior = ArithmeticGrammarPrior {
779            terminal_prob: 0.45,
780            max_depth: 3,
781            n_vars: 1,
782            p_var: 0.6,
783            const_std: 2.0,
784            n_functions: 1, // {Add}
785        };
786        let likelihood = GaussianRegression {
787            xs: xs.clone(),
788            ys,
789            noise: NoiseSpec::Fixed(0.3),
790        };
791        let model = EvolutionModel::from_likelihood(prior, likelihood);
792        let mut rng = StdRng::seed_from_u64(2026);
793        let cfg = EvoSmcConfig::default();
794        assert!(
795            cfg.crossover.is_some(),
796            "the default config must exercise the kernel"
797        );
798        let result = EvolutionSMC::run(&mut rng, &model, cfg);
799
800        let total: f64 = result.particles.iter().map(|p| p.weight).sum();
801        assert!(
802            (total - 1.0).abs() < 1e-6,
803            "weights not normalized: {total}"
804        );
805        assert!(
806            result.log_evidence.is_finite(),
807            "log evidence {}",
808            result.log_evidence
809        );
810        let model_fn = model.smc_model();
811        let decoded = fugue::decode_particles(&result.particles, &model_fn);
812        for (p, (tree, _w)) in result.particles.iter().zip(&decoded) {
813            assert!(p.trace.log_prior.is_finite());
814            assert!(tree.size() >= 1);
815        }
816        for &x in &[-1.0, 0.0, 1.5] {
817            let pred: f64 = decoded
818                .iter()
819                .map(|(tree, w)| {
820                    let v = tree.evaluate(&[x]);
821                    if v.is_finite() {
822                        w * v
823                    } else {
824                        0.0
825                    }
826                })
827                .sum();
828            assert!(
829                (pred - (x + 1.0)).abs() < 0.35,
830                "posterior predictive at {x} was {pred} vs truth {}",
831                x + 1.0
832            );
833        }
834    }
835
836    /// EV-N1: `anneal` (the advertised optimizer mode) with the default
837    /// config on a grammar prior — previously impossible (no `_with_kernel`
838    /// variant, and the default kernel panicked). Annealing must concentrate
839    /// the population on higher-fitness programs than the posterior does.
840    #[test]
841    fn test_anneal_runs_on_grammar_prior() {
842        use crate::genome::tree::{ArithmeticFunction, ArithmeticTerminal, TreeGenome};
843        use crate::inference::grammar::ArithmeticGrammarPrior;
844
845        #[derive(Clone)]
846        struct Fit {
847            xs: Vec<f64>,
848            ys: Vec<f64>,
849        }
850        impl Fitness for Fit {
851            type Genome = TreeGenome<ArithmeticTerminal, ArithmeticFunction>;
852            type Value = f64;
853            fn evaluate(&self, tree: &Self::Genome) -> f64 {
854                let sse: f64 = self
855                    .xs
856                    .iter()
857                    .zip(&self.ys)
858                    .map(|(&x, &y)| {
859                        let p = tree.evaluate(&[x]);
860                        if p.is_finite() {
861                            (p - y).powi(2)
862                        } else {
863                            1e6
864                        }
865                    })
866                    .sum();
867                -0.5 * sse / (0.3 * 0.3)
868            }
869        }
870
871        let xs: Vec<f64> = (-8..=8).map(|i| i as f64 / 4.0).collect();
872        let ys: Vec<f64> = xs.iter().map(|x| 2.0 * x + 1.0).collect();
873        let fitness = Fit { xs, ys };
874        let prior = ArithmeticGrammarPrior {
875            terminal_prob: 0.4,
876            max_depth: 3,
877            n_vars: 1,
878            p_var: 0.6,
879            const_std: 2.0,
880            n_functions: 3, // Add, Sub, Mul
881        };
882        let model = EvolutionModel::new(prior, fitness.clone());
883        let cfg = || EvoSmcConfig {
884            num_particles: 300,
885            rejuvenation_steps: 3,
886            ..Default::default()
887        };
888        let mut rng = StdRng::seed_from_u64(99);
889        let posterior = EvolutionSMC::run(&mut rng, &model, cfg());
890        let annealed = EvolutionSMC::anneal(&mut rng, &model, cfg(), 10.0, 5);
891
892        let model_fn = model.smc_model();
893        let mean_fitness = |r: &EvolutionPosterior<_>| -> f64 {
894            fugue::decode_particles(&r.particles, &model_fn)
895                .iter()
896                .map(|(t, w)| w * fitness.evaluate(t))
897                .sum()
898        };
899        let (post_f, ann_f) = (mean_fitness(&posterior), mean_fitness(&annealed));
900        assert!(ann_f.is_finite() && post_f.is_finite());
901        assert!(
902            ann_f > post_f,
903            "annealed mean fitness {ann_f} should exceed posterior mean fitness {post_f}"
904        );
905        for p in &annealed.particles {
906            assert!(p.trace.log_prior.is_finite());
907        }
908    }
909
910    /// EV-N1 mechanism test: a population whose parents disagree on a
911    /// structural site (`node#leaf`: leaf root vs function root) is swept with
912    /// `swap_probability = 1` many times. Every structural swap must be
913    /// rejected as a self-loop (no panic, address sets unchanged), while the
914    /// kernel still moves the shared, structure-preserving sites.
915    #[test]
916    fn test_shared_site_crossover_rejects_structural_mismatch() {
917        use crate::genome::tree::{ArithmeticFunction, ArithmeticTerminal, TreeGenome, TreeNode};
918        use crate::inference::grammar::ArithmeticGrammarPrior;
919
920        let prior = ArithmeticGrammarPrior::default();
921        let model_fn = || prior.model();
922        // Two leaf roots (different constants) and two function roots.
923        let leaf = |c: f64| TreeGenome::new(TreeNode::terminal(ArithmeticTerminal::Constant(c)), 6);
924        let func = |c: f64| {
925            TreeGenome::new(
926                TreeNode::function(
927                    ArithmeticFunction::Add,
928                    vec![
929                        TreeNode::terminal(ArithmeticTerminal::Variable(0)),
930                        TreeNode::terminal(ArithmeticTerminal::Constant(c)),
931                    ],
932                ),
933                6,
934            )
935        };
936        let trees = [leaf(0.5), leaf(-0.7), func(1.0), func(2.0)];
937        let mut particles: Vec<Particle> = trees
938            .iter()
939            .map(|t| {
940                let (_g, scored) = run(
941                    ScoreGivenTrace {
942                        base: prior.trace_of(t),
943                        trace: Trace::default(),
944                    },
945                    model_fn(),
946                );
947                Particle {
948                    trace: scored,
949                    log_weight: 0.0,
950                    weight: 0.25,
951                }
952            })
953            .collect();
954        let before: Vec<Vec<Address>> = particles
955            .iter()
956            .map(|p| p.trace.choices.keys().cloned().collect())
957            .collect();
958
959        let mut kernel = SharedSiteCrossover {
960            n_pairs: 400,
961            swap_probability: 1.0,
962        };
963        let mut rng = StdRng::seed_from_u64(8);
964        PopulationKernel::<TreeGenome<ArithmeticTerminal, ArithmeticFunction>>::sweep(
965            &mut kernel,
966            &mut rng,
967            &mut particles,
968            &model_fn,
969            1.0,
970        );
971
972        for (p, addrs) in particles.iter().zip(&before) {
973            let after: Vec<Address> = p.trace.choices.keys().cloned().collect();
974            assert_eq!(&after, addrs, "address set changed across a swap");
975            assert!(p.trace.log_prior.is_finite());
976            let tree = decode_particle(p, model_fn);
977            assert!(tree.size() >= 1);
978        }
979        // Structure-preserving swaps did happen: the two leaves' constants
980        // (or the two function roots' constants) were exchanged at least once
981        // — with p_swap = 1 and 400 pair draws this is certain up to
982        // acceptance, and same-structure swaps are accepted with ratio 1.
983        let consts: Vec<f64> = particles
984            .iter()
985            .filter_map(|p| p.trace.get_f64(&fugue::addr!("node", "const")))
986            .collect();
987        assert_eq!(consts.len(), 2);
988        let moved = particles.iter().any(|p| {
989            p.trace
990                .get_f64(&fugue::addr!("node/1", "const"))
991                .is_some_and(|c| c != 1.0)
992        }) || consts[0] != 0.5;
993        assert!(moved, "no structure-preserving swap was ever accepted");
994    }
995
996    /// EV-N5: a tree posterior has no `gene#i` coordinate; the readout says
997    /// so instead of returning 0.0.
998    #[test]
999    fn test_weighted_mean_is_none_without_the_coordinate() {
1000        use crate::inference::grammar::ArithmeticGrammarPrior;
1001        use crate::inference::likelihood::NoLikelihood;
1002        let model = EvolutionModel::from_likelihood(
1003            ArithmeticGrammarPrior {
1004                max_depth: 2,
1005                ..Default::default()
1006            },
1007            NoLikelihood,
1008        );
1009        let mut rng = StdRng::seed_from_u64(4);
1010        let result = EvolutionSMC::run(
1011            &mut rng,
1012            &model,
1013            EvoSmcConfig {
1014                num_particles: 20,
1015                rejuvenation_steps: 1,
1016                ..Default::default()
1017            },
1018        );
1019        assert_eq!(result.weighted_mean(0), None);
1020        assert_eq!(result.weighted_variance(0), None);
1021        // A real-vector posterior beyond its dimension is `None` too.
1022        let prior = GaussianPrior::new(0.0, 1.0, 1);
1023        let model = EvolutionModel::new(prior, PtrFitness(quad_k1_c3));
1024        let result = EvolutionSMC::run(
1025            &mut rng,
1026            &model,
1027            EvoSmcConfig {
1028                num_particles: 20,
1029                rejuvenation_steps: 1,
1030                crossover: None,
1031                ..Default::default()
1032            },
1033        );
1034        assert!(result.weighted_mean(0).is_some());
1035        assert_eq!(result.weighted_mean(1), None);
1036    }
1037
1038    pub(super) fn quad_origin_local(g: &RealVector) -> f64 {
1039        -0.5 * g.genes().iter().map(|x| x * x).sum::<f64>()
1040    }
1041
1042    /// Bounds are respected end-to-end: with a uniform-box prior every
1043    /// particle stays inside the box (out-of-box scores −∞ and can never
1044    /// survive).
1045    #[test]
1046    fn test_smc_respects_bounds() {
1047        use crate::inference::prior::UniformBoxPrior;
1048        let prior = UniformBoxPrior::new(MultiBounds::new(vec![Bounds::new(-2.0, 2.0)]));
1049        let model = EvolutionModel::new(prior, PtrFitness(|g: &RealVector| g.genes()[0]));
1050        let mut rng = StdRng::seed_from_u64(9);
1051        let result = EvolutionSMC::run(
1052            &mut rng,
1053            &model,
1054            EvoSmcConfig {
1055                num_particles: 300,
1056                rejuvenation_steps: 3,
1057                crossover: Some(CrossoverConfig::default()),
1058                ..Default::default()
1059            },
1060        );
1061        for p in &result.particles {
1062            let x = p.trace.get_f64(&fugue::addr!("gene", 0)).unwrap();
1063            assert!((-2.0..=2.0).contains(&x), "particle escaped bounds: {}", x);
1064        }
1065    }
1066}