1use 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#[derive(Clone, Debug)]
57pub struct CrossoverConfig {
58 pub n_pairs: usize,
60 pub swap_probability: f64,
62}
63
64pub 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
100fn 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
118fn 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#[derive(Clone, Debug)]
163pub struct SharedSiteCrossover {
164 pub n_pairs: usize,
166 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; }
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; };
213 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; particles[j].trace = cj; }
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
235pub struct EvoSmcConfig {
237 pub num_particles: usize,
239 pub ess_threshold: f64,
242 pub resampling: ResamplingMethod,
244 pub rejuvenation_steps: usize,
246 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
262pub struct EvolutionPosterior<G: TraceGenome> {
270 pub particles: Vec<Particle>,
272 pub log_evidence: f64,
275 _g: PhantomData<G>,
276}
277
278impl<G: TraceGenome> EvolutionPosterior<G> {
279 pub fn genome(&self, particle: &Particle, model_fn: &impl Fn() -> Model<G>) -> G {
281 decode_particle(particle, model_fn)
282 }
283
284 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 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 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 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
347pub struct EvolutionSMC;
349
350impl EvolutionSMC {
351 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 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 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 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 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 for p in &mut result.particles {
508 p.log_weight += d_beta * loglik(&p.trace);
509 }
510 normalize_particles(&mut result.particles);
511
512 result.particles = resample_particles(rng, &result.particles, resampling);
514
515 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 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 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
568pub 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 #[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 let total: f64 = result.particles.iter().map(|p| p.weight).sum();
636 assert!((total - 1.0).abs() < 1e-6);
637
638 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 #[test]
653 fn test_smc_with_crossover_matches_conjugate_posterior() {
654 let prior = GaussianPrior::new(0.0, 2.0, 2);
655 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 #[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 #[test]
725 fn test_anneal_concentrates_on_optimum() {
726 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 #[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, };
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 #[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, };
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 #[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 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 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 #[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 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 #[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}