fugue/inference/smc.rs
1//! Sequential Monte Carlo (SMC) with particle filtering and resampling.
2//!
3//! This module implements Sequential Monte Carlo methods, also known as particle filters.
4//! SMC maintains a population of weighted particles (traces) and uses resampling to
5//! focus computational effort on high-probability regions of the posterior.
6//!
7//! ## Key Features
8//!
9//! - **Multiple resampling methods**: Multinomial, Systematic, Stratified
10//! - **Effective Sample Size (ESS) monitoring**: Automatic resampling triggers
11//! - **Rejuvenation**: Optional MCMC moves to maintain particle diversity
12//! - **Adaptive resampling**: Resample only when ESS drops below threshold
13//!
14//! ## Algorithm Overview
15//!
16//! SMC works by maintaining a population of particles, each representing a possible
17//! state (parameter configuration) with an associated weight:
18//!
19//! 1. **Initialize**: Start with particles from the prior
20//! 2. **Weight**: Compute importance weights based on likelihood
21//! 3. **Resample**: When weights become uneven, resample to maintain diversity
22//! 4. **Rejuvenate**: Optionally apply MCMC moves to particles
23//! 5. **Repeat**: Continue until convergence or max iterations
24//!
25//! ## When to Use SMC
26//!
27//! SMC is particularly effective for:
28//! - Models with many observations that can be processed sequentially
29//! - High-dimensional parameter spaces where MCMC mixes poorly
30//! - Real-time inference where new data arrives continuously
31//! - Situations where you need multiple diverse posterior samples
32//!
33//! # Examples
34//!
35//! ```rust
36//! use fugue::*;
37//! use rand::rngs::StdRng;
38//! use rand::SeedableRng;
39//!
40//! // Define a simple model
41//! let model_fn = || {
42//! sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap())
43//! .bind(|mu| {
44//! observe(addr!("y"), Normal::new(mu, 0.5).unwrap(), 2.0)
45//! .map(move |_| mu)
46//! })
47//! };
48//!
49//! // Run SMC (small numbers for testing)
50//! let mut rng = StdRng::seed_from_u64(42);
51//! let config = SMCConfig::default();
52//! let particles = adaptive_smc(&mut rng, 10, model_fn, config);
53//!
54//! // Analyze results
55//! let ess = effective_sample_size(&particles);
56//! assert!(ess > 0.0);
57//! ```
58use crate::core::address::Address;
59use crate::core::model::Model;
60use crate::core::numerical::log_sum_exp;
61use crate::inference::mcmc_utils::DiminishingAdaptation;
62use crate::inference::mh::{propose_and_score, SiteProposal};
63use crate::runtime::handler::run;
64use crate::runtime::interpreters::{PriorHandler, ScoreGivenTrace};
65use crate::runtime::trace::Trace;
66use rand::Rng;
67use std::collections::HashMap;
68
69/// A weighted particle in the SMC population.
70///
71/// Each particle represents a possible state (parameter configuration) with
72/// associated weights that reflect its probability relative to other particles.
73/// The weight decomposition into linear and log space enables numerical stability.
74///
75/// # Fields
76///
77/// * `trace` - Execution trace containing parameter values and log-probabilities
78/// * `weight` - Normalized linear weight (used for resampling)
79/// * `log_weight` - Log-space weight (for numerical stability)
80///
81/// # Examples
82///
83/// ```rust
84/// use fugue::*;
85///
86/// // Particles are typically created by SMC algorithms
87/// let particle = Particle {
88/// trace: Trace::default(),
89/// weight: 0.25, // 25% of total weight
90/// log_weight: -1.386, // ln(0.25)
91/// };
92///
93/// println!("Particle weight: {:.3}", particle.weight);
94/// ```
95#[derive(Clone, Debug)]
96pub struct Particle {
97 /// Execution trace containing parameter values and log-probabilities.
98 pub trace: Trace,
99 /// Normalized linear weight (used for resampling).
100 pub weight: f64,
101 /// Log-space weight (for numerical stability).
102 pub log_weight: f64,
103}
104
105/// Resampling algorithms for particle filters.
106///
107/// Different resampling methods offer trade-offs between computational efficiency,
108/// variance reduction, and implementation complexity. All methods aim to replace
109/// low-weight particles with copies of high-weight particles.
110///
111/// # Variants
112///
113/// * `Multinomial` - Simple multinomial resampling (high variance)
114/// * `Systematic` - Low-variance systematic resampling (recommended)
115/// * `Stratified` - Stratified resampling (balanced variance/complexity)
116///
117/// # Examples
118///
119/// ```rust
120/// use fugue::*;
121///
122/// // Configure SMC with different resampling methods
123/// let config_systematic = SMCConfig {
124/// resampling_method: ResamplingMethod::Systematic,
125/// ..Default::default()
126/// };
127///
128/// let config_multinomial = SMCConfig {
129/// resampling_method: ResamplingMethod::Multinomial,
130/// ..Default::default()
131/// };
132/// ```
133#[derive(Clone, Copy, Debug)]
134pub enum ResamplingMethod {
135 /// Simple multinomial resampling with replacement.
136 Multinomial,
137 /// Low-variance systematic resampling (recommended).
138 Systematic,
139 /// Stratified resampling with balanced variance.
140 Stratified,
141}
142
143/// Configuration options for Sequential Monte Carlo.
144///
145/// This struct controls various aspects of the SMC algorithm, allowing fine-tuning
146/// of performance and accuracy trade-offs.
147///
148/// # Fields
149///
150/// * `resampling_method` - Algorithm used for particle resampling
151/// * `ess_threshold` - ESS threshold that triggers resampling (as fraction of N)
152/// * `rejuvenation_steps` - Number of MCMC moves after resampling to increase diversity
153///
154/// # Examples
155///
156/// ```rust
157/// use fugue::*;
158///
159/// // Conservative configuration (less resampling, more rejuvenation)
160/// let conservative_config = SMCConfig {
161/// resampling_method: ResamplingMethod::Systematic,
162/// ess_threshold: 0.2, // Resample when ESS < 20% of particles
163/// rejuvenation_steps: 5, // 5 MCMC moves after resampling
164/// };
165///
166/// // Aggressive configuration (frequent resampling, no rejuvenation)
167/// let aggressive_config = SMCConfig {
168/// resampling_method: ResamplingMethod::Systematic,
169/// ess_threshold: 0.8, // Resample when ESS < 80% of particles
170/// rejuvenation_steps: 0, // No rejuvenation
171/// };
172/// ```
173pub struct SMCConfig {
174 /// Algorithm used for particle resampling.
175 pub resampling_method: ResamplingMethod,
176 /// ESS threshold that triggers resampling (as fraction of particle count).
177 pub ess_threshold: f64,
178 /// Number of MCMC moves after resampling to increase diversity.
179 pub rejuvenation_steps: usize,
180}
181
182impl Default for SMCConfig {
183 fn default() -> Self {
184 Self {
185 resampling_method: ResamplingMethod::Systematic,
186 ess_threshold: 0.5,
187 rejuvenation_steps: 0,
188 }
189 }
190}
191
192/// Compute the effective sample size (ESS) of a particle population.
193///
194/// ESS measures how many "effective" independent samples the weighted particle
195/// population represents. It ranges from 1 (all weight on one particle) to N
196/// (uniform weights). Low ESS indicates weight degeneracy and triggers resampling.
197///
198/// **Formula:** ESS = 1 / Σᵢ(wᵢ²) where wᵢ are normalized weights.
199///
200/// # Arguments
201///
202/// * `particles` - Population of weighted particles
203///
204/// # Returns
205///
206/// Effective sample size (1.0 ≤ ESS ≤ N where N = particles.len()).
207///
208/// # Examples
209///
210/// ```rust
211/// use fugue::*;
212///
213/// // Uniform weights -> high ESS
214/// let uniform_particles = vec![
215/// Particle { trace: Trace::default(), weight: 0.25, log_weight: -1.386 },
216/// Particle { trace: Trace::default(), weight: 0.25, log_weight: -1.386 },
217/// Particle { trace: Trace::default(), weight: 0.25, log_weight: -1.386 },
218/// Particle { trace: Trace::default(), weight: 0.25, log_weight: -1.386 },
219/// ];
220/// let ess = effective_sample_size(&uniform_particles);
221/// assert!((ess - 4.0).abs() < 0.01); // ESS ≈ 4 (perfect)
222///
223/// // Degenerate weights -> low ESS
224/// let degenerate_particles = vec![
225/// Particle { trace: Trace::default(), weight: 0.99, log_weight: -0.01 },
226/// Particle { trace: Trace::default(), weight: 0.01, log_weight: -4.605 },
227/// ];
228/// let ess = effective_sample_size(°enerate_particles);
229/// assert!(ess < 1.1); // ESS ≈ 1 (very poor)
230/// ```
231pub fn effective_sample_size(particles: &[Particle]) -> f64 {
232 let sum_sq: f64 = particles.iter().map(|p| p.weight * p.weight).sum();
233 1.0 / sum_sq
234}
235
236/// Systematic resampling: return the resampled indices for a particle population.
237pub fn systematic_resample<R: Rng>(rng: &mut R, particles: &[Particle]) -> Vec<usize> {
238 systematic_indices(rng, &particle_weights(particles))
239}
240
241/// Stratified resampling: return the resampled indices for a particle population.
242pub fn stratified_resample<R: Rng>(rng: &mut R, particles: &[Particle]) -> Vec<usize> {
243 stratified_indices(rng, &particle_weights(particles))
244}
245
246/// Multinomial resampling: return the resampled indices for a particle population.
247pub fn multinomial_resample<R: Rng>(rng: &mut R, particles: &[Particle]) -> Vec<usize> {
248 multinomial_indices(rng, &particle_weights(particles))
249}
250
251fn particle_weights(particles: &[Particle]) -> Vec<f64> {
252 particles.iter().map(|p| p.weight).collect()
253}
254
255/// Systematic resampling on a normalized weight vector.
256fn systematic_indices<R: Rng>(rng: &mut R, weights: &[f64]) -> Vec<usize> {
257 let n = weights.len();
258 let mut indices = Vec::with_capacity(n);
259 let u = rng.gen::<f64>() / n as f64;
260
261 let mut cum_weight = 0.0;
262 let mut i = 0;
263
264 for j in 0..n {
265 let threshold = u + j as f64 / n as f64;
266 while cum_weight < threshold && i < n {
267 cum_weight += weights[i];
268 i += 1;
269 }
270 indices.push((i - 1).min(n - 1));
271 }
272 indices
273}
274
275/// Stratified resampling on a normalized weight vector.
276fn stratified_indices<R: Rng>(rng: &mut R, weights: &[f64]) -> Vec<usize> {
277 let n = weights.len();
278 let mut indices = Vec::with_capacity(n);
279
280 let mut cum_weight = 0.0;
281 let mut i = 0;
282
283 for j in 0..n {
284 let u = rng.gen::<f64>();
285 let threshold = (j as f64 + u) / n as f64;
286 while cum_weight < threshold && i < n {
287 cum_weight += weights[i];
288 i += 1;
289 }
290 indices.push((i - 1).min(n - 1));
291 }
292 indices
293}
294
295/// Multinomial resampling on a normalized weight vector.
296fn multinomial_indices<R: Rng>(rng: &mut R, weights: &[f64]) -> Vec<usize> {
297 let n = weights.len();
298 let mut indices = Vec::with_capacity(n);
299
300 for _ in 0..n {
301 let u = rng.gen::<f64>();
302 let mut cum_weight = 0.0;
303 let mut selected = n - 1;
304
305 for (i, &w) in weights.iter().enumerate() {
306 cum_weight += w;
307 if u <= cum_weight {
308 selected = i;
309 break;
310 }
311 }
312 indices.push(selected);
313 }
314 indices
315}
316
317/// Resample indices from a normalized weight vector using the chosen method.
318fn resample_indices<R: Rng>(rng: &mut R, weights: &[f64], method: ResamplingMethod) -> Vec<usize> {
319 match method {
320 ResamplingMethod::Multinomial => multinomial_indices(rng, weights),
321 ResamplingMethod::Systematic => systematic_indices(rng, weights),
322 ResamplingMethod::Stratified => stratified_indices(rng, weights),
323 }
324}
325
326/// Resample particles based on weights.
327pub fn resample_particles<R: Rng>(
328 rng: &mut R,
329 particles: &[Particle],
330 method: ResamplingMethod,
331) -> Vec<Particle> {
332 let indices = match method {
333 ResamplingMethod::Multinomial => multinomial_resample(rng, particles),
334 ResamplingMethod::Systematic => systematic_resample(rng, particles),
335 ResamplingMethod::Stratified => stratified_resample(rng, particles),
336 };
337
338 let n = particles.len();
339 let uniform_weight = 1.0 / n as f64;
340
341 indices
342 .into_iter()
343 .map(|i| {
344 let mut p = particles[i].clone();
345 p.weight = uniform_weight;
346 p.log_weight = uniform_weight.ln();
347 p
348 })
349 .collect()
350}
351
352/// Result of a likelihood-tempered Sequential Monte Carlo run.
353///
354/// In addition to the final weighted particle population, this carries the
355/// unbiased log marginal-likelihood (log-evidence) estimate accumulated across
356/// the tempering ladder — the key deliverable that motivates SMC over plain
357/// MCMC for model comparison (see finding FG-58).
358///
359/// `SMCResult` dereferences to `Vec<Particle>`, so the population can be used
360/// directly with slice/iterator methods and with [`effective_sample_size`].
361#[derive(Clone, Debug)]
362pub struct SMCResult {
363 /// Final weighted particle population approximating the posterior (β = 1).
364 pub particles: Vec<Particle>,
365 /// Unbiased estimate of the log marginal likelihood log p(y).
366 pub log_evidence: f64,
367}
368
369impl std::ops::Deref for SMCResult {
370 type Target = Vec<Particle>;
371 fn deref(&self) -> &Self::Target {
372 &self.particles
373 }
374}
375
376/// A population-coupled MCMC move applied to the whole particle slice between
377/// SMC tempering steps.
378///
379/// Unlike per-particle rejuvenation ([`rejuvenate_particles`]), a population
380/// kernel may *couple* particles — e.g. a crossover move that swaps a block of
381/// choices between two parent traces. It is invoked by
382/// [`adaptive_smc_with_kernel`] immediately after resampling and per-particle
383/// rejuvenation, on a uniform-weight population that is (approximately)
384/// distributed according to the current tempered target π_β.
385///
386/// # Invariance contract (MUST hold, or SMC estimates are biased)
387///
388/// For a single model execution, `π_β(θ) ∝ p(θ) · p(y|θ)^β`. A kernel that
389/// couples the pair (i, j) MUST leave the **product target**
390/// `π_β(θ_i) · π_β(θ_j)` invariant (and analogously for any k-tuple it
391/// couples). Concretely the implementation MUST:
392///
393/// * **(W)** never write `particle.weight` or `particle.log_weight` — after
394/// resampling the weights are uniform and an invariant move keeps them
395/// uniform (findings FG-03 / FG-13). Reweighting here re-introduces the
396/// prior-squaring bias FG-03 fixed.
397/// * **(T)** mutate only `particle.trace`, and only to a value obtained by a
398/// Metropolis accept/reject whose target is the product of the coupled
399/// particles' tempered densities.
400/// * **(S)** re-score every trace it writes under `model_fn` (via
401/// [`ScoreGivenTrace`] or
402/// [`score_given_trace_reconciled`](crate::runtime::interpreters::score_given_trace_reconciled))
403/// so the three log accumulators are valid — direct choice surgery does NOT
404/// update them (see [`Trace::insert_choice`]).
405/// * **(E)** not read or mutate the SMC log-evidence accumulator (it has no
406/// access to it) — an invariant move contributes no incremental weight
407/// (FG-58).
408///
409/// # Correctness of the built-in crossover move
410///
411/// Between tempering steps the uniform-weight population is approximately
412/// i.i.d. from π_β, so a coupled pair (θ_i, θ_j) is distributed as
413/// `π_β ⊗ π_β`. [`CrossoverKernel`] draws an address mask S from a
414/// value-independent, pair-symmetric distribution and deterministically swaps
415/// the values on S. This map is an **involution** (swapping S back recovers
416/// the parents) with identical forward/reverse mask distributions, so
417/// `q(child|parent) = q(parent|child)` and the Hastings correction is 1. The
418/// Metropolis acceptance
419/// `α = min(1, [π_β(θ_i')·π_β(θ_j')] / [π_β(θ_i)·π_β(θ_j)])` is therefore a
420/// valid Metropolis move on `π_β ⊗ π_β`, hence product-invariant; applied to a
421/// π_β population it preserves each particle's marginal and keeps the uniform
422/// weights correct (W). Being invariant it injects zero incremental weight, so
423/// the log-evidence accumulator is untouched (E). The re-score (S) makes
424/// off-support swaps (`guard` / `factor(-∞)`) reject via a `-∞` density —
425/// support-respecting truncation.
426///
427/// The kernel is object-safe: `rng` is `&mut dyn RngCore` (rand's blanket
428/// `impl<R: RngCore + ?Sized> Rng for R` supplies the sampling methods), and
429/// the model constructor is passed as `&dyn Fn`.
430pub trait PopulationKernel<A> {
431 /// Apply one population sweep in place. `beta` is the current tempering
432 /// exponent; `model_fn` reconstructs the single-execution model whose
433 /// tempered density defines the (product) target.
434 fn sweep(
435 &mut self,
436 rng: &mut dyn rand::RngCore,
437 particles: &mut [Particle],
438 model_fn: &dyn Fn() -> Model<A>,
439 beta: f64,
440 );
441}
442
443/// The identity population kernel: does nothing. [`adaptive_smc`] is defined
444/// as `adaptive_smc_with_kernel(.., &mut NoKernel)`.
445pub struct NoKernel;
446
447impl<A> PopulationKernel<A> for NoKernel {
448 fn sweep(
449 &mut self,
450 _: &mut dyn rand::RngCore,
451 _: &mut [Particle],
452 _: &dyn Fn() -> Model<A>,
453 _: f64,
454 ) {
455 }
456}
457
458/// A population crossover kernel: repeatedly picks two distinct particles at
459/// random, proposes a child pair by swapping the block of choices at the
460/// addresses chosen by `mask`, and accepts the swap with the product-target
461/// Metropolis ratio (see the correctness argument on [`PopulationKernel`]).
462///
463/// # v1 scope: fixed-structure models
464///
465/// The swap is exact for models whose address set is identical across
466/// executions (bit-string / permutation / real-vector genomes and other
467/// fixed-structure models): the swap is dimension-preserving and the
468/// [`ScoreGivenTrace`] re-score is exact. For variable-dimension models a
469/// swapped block can leave a partner with an incomplete assignment; such
470/// crossovers need a custom kernel built on
471/// [`score_given_trace_reconciled`](crate::runtime::interpreters::score_given_trace_reconciled)
472/// carrying the RJMCMC dimension bookkeeping.
473pub struct CrossoverKernel {
474 /// Number of (pair, swap) proposals per sweep.
475 pub n_pairs: usize,
476 /// Chooses the address set S to swap. Given the two parent traces it
477 /// returns the addresses whose values are exchanged between the pair. MUST
478 /// be value-independent (depend only on the address structure) and
479 /// symmetric in its two trace arguments for the move to be a symmetric
480 /// involution (Hastings ratio 1).
481 #[allow(clippy::type_complexity)]
482 pub mask: Box<dyn Fn(&Trace, &Trace, &mut dyn rand::RngCore) -> Vec<Address>>,
483}
484
485/// Build two children by exchanging the choices at `swap` between `a` and `b`.
486/// Pure choice surgery: the children's accumulators are NOT valid until
487/// re-scored (contract S of [`PopulationKernel`]).
488fn swap_block(a: &Trace, b: &Trace, swap: &[Address]) -> (Trace, Trace) {
489 let mut ca = a.clone();
490 let mut cb = b.clone();
491 for addr in swap {
492 let from_a = ca.choices.remove(addr);
493 let from_b = cb.choices.remove(addr);
494 if let Some(c) = from_b {
495 ca.choices.insert(addr.clone(), c);
496 }
497 if let Some(c) = from_a {
498 cb.choices.insert(addr.clone(), c);
499 }
500 }
501 (ca, cb)
502}
503
504impl<A> PopulationKernel<A> for CrossoverKernel {
505 fn sweep(
506 &mut self,
507 rng: &mut dyn rand::RngCore,
508 particles: &mut [Particle],
509 model_fn: &dyn Fn() -> Model<A>,
510 beta: f64,
511 ) {
512 let n = particles.len();
513 if n < 2 {
514 return;
515 }
516 for _ in 0..self.n_pairs {
517 let i = rng.gen_range(0..n);
518 let mut j = rng.gen_range(0..n - 1);
519 if j >= i {
520 j += 1; // distinct partner
521 }
522 let s = (self.mask)(&particles[i].trace, &particles[j].trace, rng);
523 if s.is_empty() {
524 continue;
525 }
526
527 // Build child traces by choice surgery, then RE-SCORE (contract S).
528 let (ti, tj) = swap_block(&particles[i].trace, &particles[j].trace, &s);
529 let (_ai, ci) = run(
530 ScoreGivenTrace {
531 base: ti,
532 trace: Trace::default(),
533 },
534 model_fn(),
535 );
536 let (_aj, cj) = run(
537 ScoreGivenTrace {
538 base: tj,
539 trace: Trace::default(),
540 },
541 model_fn(),
542 );
543
544 // Tempered log-density of a single execution:
545 // log π_β(θ) = log_prior + β·(log_likelihood + log_factors).
546 let logd = |t: &Trace| t.log_prior + beta * (t.log_likelihood + t.log_factors);
547 let log_alpha =
548 (logd(&ci) + logd(&cj)) - (logd(&particles[i].trace) + logd(&particles[j].trace));
549 if log_alpha >= 0.0 || rng.gen::<f64>() < log_alpha.exp() {
550 particles[i].trace = ci; // (T): only traces move;
551 particles[j].trace = cj; // (W): weights untouched.
552 }
553 }
554 }
555}
556
557/// The log incremental target factor of a particle: log p(y | θ) = log_likelihood + log_factors.
558///
559/// Under likelihood tempering the sequence of targets is
560/// π_β(θ) ∝ p(θ) · p(y | θ)^β, so the base prior draw contributes p(θ) and the
561/// tempered reweighting uses only this likelihood term. This is also the correct
562/// (prior-cancelled) importance weight of finding FG-03.
563fn particle_log_likelihood(trace: &Trace) -> f64 {
564 trace.log_likelihood + trace.log_factors
565}
566
567/// Run genuine likelihood-tempered Sequential Monte Carlo.
568///
569/// This targets the sequence of tempered distributions
570/// π_β(θ) ∝ p(θ) · p(y | θ)^β for β increasing 0 → 1, so π_0 is the prior and
571/// π_1 is the posterior. It performs:
572///
573/// 1. **Initialization** — draw `num_particles` particles from the prior (β = 0),
574/// with uniform weights.
575/// 2. **Adaptive tempering** — pick the next β by bisection so the reweighted ESS
576/// hits `ess_threshold · N` (Jasra et al. 2011); reweight by the incremental
577/// factor exp((β' − β)·log p(y | θ)).
578/// 3. **Evidence accumulation** — add the log-mean incremental weight of each step
579/// to an unbiased log-evidence accumulator (finding FG-58).
580/// 4. **Resample + rejuvenate** — when `rejuvenation_steps > 0`, systematically
581/// resample and apply π_β-invariant MH moves after each intermediate step to
582/// restore particle diversity.
583///
584/// The terminal β = 1 step returns the *weighted* particles (no terminal
585/// resample, per finding FG-43): resampling as the final operation would discard
586/// information and inflate Monte Carlo variance.
587///
588/// # Arguments
589///
590/// * `rng` - Random number generator
591/// * `num_particles` - Size of particle population to maintain
592/// * `model_fn` - Function that creates the model
593/// * `config` - SMC configuration (resampling method, ESS threshold, rejuvenation)
594///
595/// # Returns
596///
597/// An [`SMCResult`] with the final weighted particles and the log-evidence estimate.
598///
599/// # Examples
600///
601/// ```rust
602/// use fugue::*;
603/// use rand::rngs::StdRng;
604/// use rand::SeedableRng;
605///
606/// // Simple model for testing
607/// let model_fn = || {
608/// sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap())
609/// .bind(|mu| {
610/// observe(addr!("y"), Normal::new(mu, 0.5).unwrap(), 1.8)
611/// .map(move |_| mu)
612/// })
613/// };
614///
615/// // Run SMC with small numbers for testing
616/// let mut rng = StdRng::seed_from_u64(42);
617/// let config = SMCConfig {
618/// resampling_method: ResamplingMethod::Systematic,
619/// ess_threshold: 0.5,
620/// rejuvenation_steps: 1,
621/// };
622///
623/// let result = adaptive_smc(&mut rng, 5, model_fn, config);
624/// assert!(result.log_evidence.is_finite());
625///
626/// // Analyze posterior
627/// let mu_estimates: Vec<f64> = result.iter()
628/// .filter_map(|p| p.trace.choices.get(&addr!("mu")))
629/// .filter_map(|choice| match choice.value {
630/// ChoiceValue::F64(mu) => Some(mu),
631/// _ => None,
632/// })
633/// .collect();
634///
635/// assert!(!mu_estimates.is_empty());
636/// ```
637pub fn adaptive_smc<A, R: Rng>(
638 rng: &mut R,
639 num_particles: usize,
640 model_fn: impl Fn() -> Model<A>,
641 config: SMCConfig,
642) -> SMCResult {
643 adaptive_smc_with_kernel(rng, num_particles, model_fn, config, &mut NoKernel)
644}
645
646/// Likelihood-tempered SMC with a population-coupled kernel applied between
647/// tempering steps.
648///
649/// Identical to [`adaptive_smc`] except that after each intermediate step's
650/// resample + per-particle rejuvenation (and before the likelihood refresh),
651/// `kernel.sweep(..)` is invoked on the whole particle slice at the current β.
652/// This is the hook for population-coupled MCMC moves — e.g. crossover between
653/// particle pairs ([`CrossoverKernel`]) — which per-particle rejuvenation
654/// cannot express. With [`NoKernel`] this is exactly [`adaptive_smc`].
655///
656/// The kernel runs only at **intermediate** tempering steps: the terminal
657/// β = 1 step returns the weighted particles without resampling or moves
658/// (FG-43), so the kernel never touches the returned weighted population. See
659/// [`PopulationKernel`] for the invariance contract the kernel must satisfy.
660pub fn adaptive_smc_with_kernel<A, R, K>(
661 rng: &mut R,
662 num_particles: usize,
663 model_fn: impl Fn() -> Model<A>,
664 config: SMCConfig,
665 kernel: &mut K,
666) -> SMCResult
667where
668 R: Rng,
669 K: PopulationKernel<A>,
670{
671 let n = num_particles;
672 if n == 0 {
673 return SMCResult {
674 particles: Vec::new(),
675 log_evidence: 0.0,
676 };
677 }
678
679 // Step 1: draw the initial population from the prior (β = 0, uniform weights).
680 let mut particles = smc_prior_particles(rng, n, &model_fn);
681 let mut logliks: Vec<f64> = particles
682 .iter()
683 .map(|p| particle_log_likelihood(&p.trace))
684 .collect();
685 // Normalized log-weights (invariant: sum of exp equals 1). Uniform at β = 0.
686 let mut log_w = vec![-(n as f64).ln(); n];
687
688 let mut beta = 0.0_f64;
689 let mut log_evidence = 0.0_f64;
690 // Target ESS for the adaptive β schedule.
691 let target_ess = (config.ess_threshold * n as f64).clamp(1.0, n as f64);
692 let mut adaptation = DiminishingAdaptation::new(0.44, 0.7);
693
694 if config.rejuvenation_steps == 0 {
695 // Without a rejuvenation move the particle positions never change, so a
696 // multi-step temper and a single 0→1 jump give identical weighted
697 // populations. Resampling here would only add variance (FG-43), so we do
698 // a single pure importance-sampling reweight: log Ẑ = log-mean-likelihood
699 // and weights ∝ exp(loglik). This is also the FG-03 prior-cancelled weight.
700 let combined: Vec<f64> = logliks.iter().map(|ll| -(n as f64).ln() + ll).collect();
701 log_evidence = log_sum_exp(&combined);
702 beta = 1.0;
703 log_w = combined;
704 } else {
705 // Genuine likelihood-tempered SMC. Because we resample (restart from
706 // uniform weights) at every intermediate step, each `next_beta` search
707 // begins from ESS = N > target and is guaranteed to make progress toward
708 // β = 1. A hard cap on the number of steps is a final safety net.
709 const MAX_STEPS: usize = 10_000;
710 let mut steps = 0;
711 while beta < 1.0 {
712 steps += 1;
713 let mut beta_new = next_beta(beta, &log_w, &logliks, target_ess);
714 if steps >= MAX_STEPS {
715 beta_new = 1.0;
716 }
717 let d_beta = beta_new - beta;
718
719 // Reweight by the incremental likelihood factor and accumulate
720 // evidence. Since `log_w` is uniform at the start of every step, this
721 // step's contribution is the log-mean incremental weight (FG-58).
722 let combined: Vec<f64> = log_w
723 .iter()
724 .zip(&logliks)
725 .map(|(lw, ll)| lw + d_beta * ll)
726 .collect();
727 let log_norm = log_sum_exp(&combined);
728 log_evidence += log_norm;
729
730 if log_norm.is_finite() {
731 for (lw, c) in log_w.iter_mut().zip(&combined) {
732 *lw = c - log_norm;
733 }
734 } else {
735 for lw in log_w.iter_mut() {
736 *lw = -(n as f64).ln();
737 }
738 }
739 beta = beta_new;
740
741 // Resample + rejuvenate at intermediate steps only. The terminal
742 // β = 1 step returns the weighted particles (no terminal resample,
743 // FG-43).
744 if beta < 1.0 {
745 let weights: Vec<f64> = log_w.iter().map(|lw| lw.exp()).collect();
746 let indices = resample_indices(rng, &weights, config.resampling_method);
747 particles = indices.iter().map(|&i| particles[i].clone()).collect();
748 for lw in log_w.iter_mut() {
749 *lw = -(n as f64).ln();
750 }
751
752 // π_β-invariant MH rejuvenation. Weights stay uniform (FG-13): an
753 // invariant move does not change them, so we do NOT reweight here.
754 for particle in particles.iter_mut() {
755 for _ in 0..config.rejuvenation_steps {
756 particle.trace = tempered_single_site_mh(
757 rng,
758 &model_fn,
759 &particle.trace,
760 beta,
761 &mut adaptation,
762 );
763 }
764 }
765 // Population-coupled kernel sweep (π_β⊗…⊗π_β-invariant, weights
766 // untouched — see the PopulationKernel contract). Runs before the
767 // likelihood refresh below so moved traces are picked up.
768 kernel.sweep(
769 rng as &mut dyn rand::RngCore,
770 &mut particles,
771 &model_fn,
772 beta,
773 );
774 logliks = particles
775 .iter()
776 .map(|p| particle_log_likelihood(&p.trace))
777 .collect();
778 }
779 }
780 }
781 let _ = beta;
782
783 // Attach the final normalized weights to the particles.
784 let log_norm = log_sum_exp(&log_w);
785 for (p, &lw) in particles.iter_mut().zip(&log_w) {
786 if log_norm.is_finite() {
787 let normalized = lw - log_norm;
788 p.log_weight = normalized;
789 p.weight = normalized.exp();
790 } else {
791 p.log_weight = -(n as f64).ln();
792 p.weight = 1.0 / n as f64;
793 }
794 }
795
796 SMCResult {
797 particles,
798 log_evidence,
799 }
800}
801
802/// Choose the next inverse-temperature β' ∈ (β, 1] by ESS bisection.
803///
804/// Finds the smallest β' such that reweighting the current (normalized) weights
805/// by exp((β' − β)·loglik) drops the ESS to `target_ess`. If reaching β' = 1
806/// already keeps ESS ≥ `target_ess`, the ladder terminates at 1.
807fn next_beta(beta: f64, log_w: &[f64], logliks: &[f64], target_ess: f64) -> f64 {
808 let ess_at = |b: f64| -> f64 {
809 let lv: Vec<f64> = log_w
810 .iter()
811 .zip(logliks)
812 .map(|(lw, ll)| lw + (b - beta) * ll)
813 .collect();
814 let lse1 = log_sum_exp(&lv);
815 let lv2: Vec<f64> = lv.iter().map(|x| 2.0 * x).collect();
816 let lse2 = log_sum_exp(&lv2);
817 if !lse1.is_finite() || !lse2.is_finite() {
818 return log_w.len() as f64;
819 }
820 (2.0 * lse1 - lse2).exp()
821 };
822
823 // If a full jump to β = 1 keeps ESS above target, we are done.
824 if ess_at(1.0) >= target_ess {
825 return 1.0;
826 }
827
828 // Bisection: ess_at is decreasing in b; find the crossing with target_ess.
829 let mut lo = beta;
830 let mut hi = 1.0;
831 for _ in 0..64 {
832 let mid = 0.5 * (lo + hi);
833 if ess_at(mid) < target_ess {
834 hi = mid;
835 } else {
836 lo = mid;
837 }
838 }
839 // `hi` is on the low-ESS side, so ESS(hi) ≤ target. Guarantee strict progress.
840 hi.max(beta + 1e-9).min(1.0)
841}
842
843/// A single π_β-invariant single-site Metropolis-Hastings rejuvenation move.
844///
845/// Picks the target uniformly over **all** of the trace's sites and dispatches
846/// the proposal by value type through the same typed machinery as
847/// [`adaptive_single_site_mh`](crate::inference::mh::adaptive_single_site_mh):
848/// Gaussian/log-space/reflected walks for `F64`, a deterministic flip for
849/// `Bool`, a reflected discrete walk for `U64`, prior-resample for `Usize`
850/// categoricals, and an integer walk for `I64`. (The previous implementation
851/// collected only `F64` sites, so populations of pure Bool/Usize/U64 traces —
852/// e.g. bit-string or permutation genomes — never moved during rejuvenation.)
853///
854/// Acceptance is the tempered trans-dimensional ratio
855///
856/// ```text
857/// log α = Δlog_prior + β·Δloglik + (log q_rev − log q_fwd) + dim_term
858/// ```
859///
860/// against π_β(θ) ∝ p(θ)·p(y|θ)^β. Births/deaths of prior-proposed structure
861/// carry their RJMCMC corrections in `log q_fwd`/`log q_rev` (the prior is
862/// untempered in π_β, so the usual prior-cancellation still holds), and
863/// `dim_term = ln|sites(current)| − ln|sites(proposed)|` (FG-20/FG-21; 0 for
864/// fixed-structure models). The move is invariant for π_β, so applying it to a
865/// resampled (uniform-weight) population leaves the weights uniform.
866fn tempered_single_site_mh<A, R: Rng>(
867 rng: &mut R,
868 model_fn: &impl Fn() -> Model<A>,
869 current: &Trace,
870 beta: f64,
871 adaptation: &mut DiminishingAdaptation,
872) -> Trace {
873 if current.choices.is_empty() {
874 // Nothing to move; doing nothing is trivially π_β-invariant.
875 return current.clone();
876 }
877
878 let sites: Vec<Address> = current.choices.keys().cloned().collect();
879 let target = sites[rng.gen_range(0..sites.len())].clone();
880 let scale = adaptation.get_scale(&target);
881
882 let overrides: HashMap<Address, SiteProposal> = HashMap::new();
883 let mut kind_cache: HashMap<Address, SiteProposal> = HashMap::new();
884 let (_a_prop, prop_trace, _prop_lw, lqf, lqr, _structure_changed) = propose_and_score(
885 rng,
886 model_fn,
887 current,
888 &target,
889 scale,
890 &overrides,
891 &mut kind_cache,
892 );
893
894 // Score the current state (also refreshes accumulators for the trace we
895 // return on rejection, FG-40).
896 let (_, cur_scored) = run(
897 ScoreGivenTrace {
898 base: current.clone(),
899 trace: Trace::default(),
900 },
901 model_fn(),
902 );
903
904 let dim_term = (sites.len() as f64).ln() - (prop_trace.choices.len() as f64).ln();
905 let log_alpha = (prop_trace.log_prior - cur_scored.log_prior)
906 + beta * (particle_log_likelihood(&prop_trace) - particle_log_likelihood(&cur_scored))
907 + (lqr - lqf)
908 + dim_term;
909 let accept = log_alpha >= 0.0 || rng.gen::<f64>() < log_alpha.exp();
910 adaptation.update(&target, accept);
911
912 if accept {
913 prop_trace
914 } else {
915 cur_scored
916 }
917}
918
919/// Apply π_β-invariant MH rejuvenation moves to a particle population in place.
920///
921/// This is the rejuvenation primitive used by [`adaptive_smc`]. It updates each
922/// particle's trace with `rejuvenation_steps` single-site MH moves that leave the
923/// tempered target π_β invariant. Crucially it does **not** touch particle weights:
924/// after resampling the weights are uniform, and an invariant MH move keeps them
925/// uniform — reweighting here would re-introduce the prior-squaring bias of
926/// findings FG-03/FG-13.
927pub fn rejuvenate_particles<A, R: Rng>(
928 rng: &mut R,
929 particles: &mut [Particle],
930 model_fn: impl Fn() -> Model<A>,
931 beta: f64,
932 rejuvenation_steps: usize,
933) {
934 let mut adaptation = DiminishingAdaptation::new(0.44, 0.7);
935 for particle in particles.iter_mut() {
936 for _ in 0..rejuvenation_steps {
937 particle.trace =
938 tempered_single_site_mh(rng, &model_fn, &particle.trace, beta, &mut adaptation);
939 }
940 // FG-13: weights are intentionally left unchanged.
941 }
942}
943
944/// Normalize particle weights using numerically stable log-sum-exp.
945///
946/// This function properly handles extreme log-weights without underflow or overflow,
947/// which is critical for reliable SMC performance.
948pub fn normalize_particles(particles: &mut [Particle]) {
949 use crate::core::numerical::log_sum_exp;
950
951 if particles.is_empty() {
952 return;
953 }
954
955 // Collect log weights
956 let log_weights: Vec<f64> = particles.iter().map(|p| p.log_weight).collect();
957
958 // Compute log normalizing constant stably
959 let log_norm = log_sum_exp(&log_weights);
960
961 // Handle degenerate case where all weights are -∞
962 if log_norm.is_infinite() && log_norm < 0.0 {
963 let n = particles.len();
964 for p in particles {
965 p.weight = 1.0 / n as f64; // Uniform weights as fallback
966 }
967 return;
968 }
969
970 // Normalize weights stably
971 for (p, &log_w) in particles.iter_mut().zip(&log_weights) {
972 p.weight = (log_w - log_norm).exp();
973 }
974
975 // Ensure weights sum to 1.0 (handle small numerical errors)
976 let weight_sum: f64 = particles.iter().map(|p| p.weight).sum();
977 if weight_sum > 0.0 {
978 for p in particles {
979 p.weight /= weight_sum;
980 }
981 }
982}
983
984/// Draw an importance-weighted particle population from the prior.
985///
986/// Each particle is a full model execution sampled from the prior (β = 0). Its
987/// unnormalized log-weight is the log-likelihood only — `log_likelihood +
988/// log_factors` — because the proposal (the prior) exactly cancels the prior
989/// factor of the target: with q(θ) = p(θ) and target ∝ p(θ)·p(y|θ), the
990/// self-normalized importance weight is p(y|θ), not p(θ)·p(y|θ). Including the
991/// log-prior term double-counts (squares) the prior and biases every posterior
992/// estimate — this is finding FG-03.
993pub fn smc_prior_particles<A, R: Rng>(
994 rng: &mut R,
995 num_particles: usize,
996 model_fn: impl Fn() -> Model<A>,
997) -> Vec<Particle> {
998 let mut particles = Vec::with_capacity(num_particles);
999 for _ in 0..num_particles {
1000 let (_a, t) = run(
1001 PriorHandler {
1002 rng,
1003 trace: Trace::default(),
1004 },
1005 model_fn(),
1006 );
1007 // FG-03: prior-proposed weight is the likelihood factor only (the prior
1008 // cancels against the proposal). FG-59: compute the weight from a borrow,
1009 // then move `t` into the particle instead of cloning the whole trace.
1010 let log_weight = particle_log_likelihood(&t);
1011 particles.push(Particle {
1012 trace: t,
1013 weight: 0.0, // Will be set by normalization
1014 log_weight,
1015 });
1016 }
1017 normalize_particles(&mut particles);
1018 particles
1019}
1020
1021/// Recover the model return value (e.g. a decoded genome) from a particle's
1022/// trace by replaying the model against it.
1023///
1024/// Uses [`ScoreGivenTrace`], which requires the trace to be a complete
1025/// assignment for `model_fn` — always true for particles produced by
1026/// [`adaptive_smc`] / [`adaptive_smc_with_kernel`] with the same model. Costs
1027/// one model execution. `Particle` deliberately does not cache the return
1028/// value: storing it would force `A: Clone` through every resample/rejuvenation
1029/// path and break the move-not-clone particle construction (FG-59); decode is
1030/// deterministic given the trace, so nothing is lost.
1031///
1032/// For traces of uncertain provenance (where a site may be missing or
1033/// type-mismatched), use [`try_decode_particle`] instead — `ScoreGivenTrace`
1034/// panics on an incomplete assignment.
1035pub fn decode_particle<A>(particle: &Particle, model_fn: impl Fn() -> Model<A>) -> A {
1036 let (a, _) = run(
1037 ScoreGivenTrace {
1038 base: particle.trace.clone(),
1039 trace: Trace::default(),
1040 },
1041 model_fn(),
1042 );
1043 a
1044}
1045
1046/// Fallible sibling of [`decode_particle`] for traces of uncertain provenance,
1047/// backed by [`SafeScoreGivenTrace`](crate::runtime::interpreters::SafeScoreGivenTrace):
1048/// a missing or type-mismatched site returns `Err` instead of panicking.
1049///
1050/// The failure signal is the safe scorer's `-∞` `log_prior` sentinel: a trace
1051/// that IS a complete, in-support assignment for `model_fn` always scores a
1052/// finite log-prior, so a non-finite one means the trace does not decode under
1053/// this model.
1054pub fn try_decode_particle<A>(
1055 particle: &Particle,
1056 model_fn: impl Fn() -> Model<A>,
1057) -> crate::error::FugueResult<A> {
1058 let (a, scored) = run(
1059 crate::runtime::interpreters::SafeScoreGivenTrace {
1060 base: particle.trace.clone(),
1061 trace: Trace::default(),
1062 warn_on_error: false,
1063 },
1064 model_fn(),
1065 );
1066 if scored.log_prior.is_finite() {
1067 Ok(a)
1068 } else {
1069 Err(crate::error::FugueError::trace_error(
1070 "try_decode_particle",
1071 None,
1072 "particle trace is not a complete in-support assignment for this model",
1073 crate::error::ErrorCode::TraceAddressNotFound,
1074 ))
1075 }
1076}
1077
1078/// Decode a whole population, pairing each decoded value with its normalized
1079/// weight — the shape posterior readouts (weighted mean / argmax) consume.
1080pub fn decode_particles<A>(
1081 particles: &[Particle],
1082 model_fn: impl Fn() -> Model<A>,
1083) -> Vec<(A, f64)> {
1084 particles
1085 .iter()
1086 .map(|p| (decode_particle(p, &model_fn), p.weight))
1087 .collect()
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093 use crate::addr;
1094 use crate::core::distribution::*;
1095 use crate::core::model::{observe, sample, ModelExt};
1096 use rand::rngs::StdRng;
1097 use rand::SeedableRng;
1098
1099 #[test]
1100 fn ess_and_resampling_behave() {
1101 // Construct 4 particles with uneven weights
1102 let particles = vec![
1103 Particle {
1104 trace: Trace::default(),
1105 weight: 0.7,
1106 log_weight: (0.7f64).ln(),
1107 },
1108 Particle {
1109 trace: Trace::default(),
1110 weight: 0.2,
1111 log_weight: (0.2f64).ln(),
1112 },
1113 Particle {
1114 trace: Trace::default(),
1115 weight: 0.09,
1116 log_weight: (0.09f64).ln(),
1117 },
1118 Particle {
1119 trace: Trace::default(),
1120 weight: 0.01,
1121 log_weight: (0.01f64).ln(),
1122 },
1123 ];
1124 let ess_val = effective_sample_size(&particles);
1125 assert!(ess_val < particles.len() as f64);
1126
1127 // Resampling indices should be valid and length preserved
1128 let mut rng = StdRng::seed_from_u64(1);
1129 let idx_m = multinomial_resample(&mut rng, &particles);
1130 assert_eq!(idx_m.len(), particles.len());
1131
1132 let idx_s = systematic_resample(&mut rng, &particles);
1133 assert_eq!(idx_s.len(), particles.len());
1134
1135 let idx_t = stratified_resample(&mut rng, &particles);
1136 assert_eq!(idx_t.len(), particles.len());
1137
1138 // Resample and check normalized uniform weights
1139 let resampled = resample_particles(&mut rng, &particles, ResamplingMethod::Systematic);
1140 let sum_w: f64 = resampled.iter().map(|p| p.weight).sum();
1141 assert!((sum_w - 1.0).abs() < 1e-12);
1142 for p in &resampled {
1143 assert!((p.weight - 0.25).abs() < 1e-12);
1144 }
1145 }
1146
1147 #[test]
1148 fn normalize_particles_handles_neg_inf() {
1149 let mut particles = vec![
1150 Particle {
1151 trace: Trace::default(),
1152 weight: 0.0,
1153 log_weight: f64::NEG_INFINITY,
1154 },
1155 Particle {
1156 trace: Trace::default(),
1157 weight: 0.0,
1158 log_weight: f64::NEG_INFINITY,
1159 },
1160 ];
1161 normalize_particles(&mut particles);
1162 // Fallback to uniform
1163 assert!((particles[0].weight - 0.5).abs() < 1e-12);
1164 assert!((particles[1].weight - 0.5).abs() < 1e-12);
1165 }
1166
1167 /// Regression (EA-as-PPL F1): rejuvenation must move non-F64 sites. The
1168 /// previous kernel collected only `ChoiceValue::F64` sites and returned
1169 /// `current.clone()` otherwise, so a population of pure-Bool traces (a
1170 /// bit-string genome) was frozen forever.
1171 #[test]
1172 fn test_smc_rejuvenation_moves_bitstring() {
1173 let n_bits = 4usize;
1174 let model_fn = move || {
1175 let bits: Vec<Model<bool>> = (0..n_bits)
1176 .map(|i| sample(addr!("bit", i), Bernoulli::new(0.5).unwrap()))
1177 .collect();
1178 crate::core::model::sequence_vec(bits).bind(|bs| {
1179 let k = bs.iter().filter(|&&b| b).count() as f64;
1180 crate::core::model::factor(k).map(move |_| bs)
1181 })
1182 };
1183
1184 // Direct movement check: a population cloned from ONE prior draw must
1185 // diversify under rejuvenation (before the fix: bit-identical forever).
1186 let mut rng = StdRng::seed_from_u64(11);
1187 let seed_particles = smc_prior_particles(&mut rng, 1, model_fn);
1188 let mut particles: Vec<Particle> = (0..20).map(|_| seed_particles[0].clone()).collect();
1189 rejuvenate_particles(&mut rng, &mut particles, model_fn, 1.0, 5);
1190 let moved = particles.iter().any(|p| {
1191 (0..n_bits).any(|i| {
1192 p.trace.get_bool(&addr!("bit", i))
1193 != seed_particles[0].trace.get_bool(&addr!("bit", i))
1194 })
1195 });
1196 assert!(
1197 moved,
1198 "Bool-only population did not move under rejuvenation"
1199 );
1200
1201 // Analytic marginal check: per-bit posterior p(1) = e/(1+e) ≈ 0.7311.
1202 let config = SMCConfig {
1203 resampling_method: ResamplingMethod::Systematic,
1204 ess_threshold: 0.5,
1205 rejuvenation_steps: 2,
1206 };
1207 let result = adaptive_smc(&mut rng, 400, model_fn, config);
1208 let p1 = std::f64::consts::E / (1.0 + std::f64::consts::E);
1209 for i in 0..n_bits {
1210 let mean: f64 = result
1211 .iter()
1212 .map(|p| {
1213 let b = p.trace.get_bool(&addr!("bit", i)).unwrap();
1214 p.weight * if b { 1.0 } else { 0.0 }
1215 })
1216 .sum();
1217 assert!(
1218 (mean - p1).abs() < 0.09,
1219 "bit {} posterior mean {} vs analytic {}",
1220 i,
1221 mean,
1222 p1
1223 );
1224 }
1225 }
1226
1227 /// Helper: two independent Normal(0,1) sites each observed with sd 1.
1228 /// Posterior per site: Normal(y_i/2, 1/2).
1229 fn two_site_model(y0: f64, y1: f64) -> impl Fn() -> Model<(f64, f64)> + Clone {
1230 move || {
1231 sample(addr!("x", 0), Normal::new(0.0, 1.0).unwrap()).and_then(move |x0| {
1232 sample(addr!("x", 1), Normal::new(0.0, 1.0).unwrap()).and_then(move |x1| {
1233 observe(addr!("y", 0), Normal::new(x0, 1.0).unwrap(), y0).and_then(move |_| {
1234 observe(addr!("y", 1), Normal::new(x1, 1.0).unwrap(), y1)
1235 .map(move |_| (x0, x1))
1236 })
1237 })
1238 })
1239 }
1240 }
1241
1242 /// A value-independent, pair-symmetric mask: each of the two sites is
1243 /// included in the swap independently with probability 1/2.
1244 #[allow(clippy::type_complexity)]
1245 fn random_site_mask() -> Box<dyn Fn(&Trace, &Trace, &mut dyn rand::RngCore) -> Vec<Address>> {
1246 Box::new(|a: &Trace, _b: &Trace, rng: &mut dyn rand::RngCore| {
1247 a.choices
1248 .keys()
1249 .filter(|_| rng.gen::<bool>())
1250 .cloned()
1251 .collect()
1252 })
1253 }
1254
1255 /// EA-as-PPL F4: the crossover kernel is π⊗π-invariant — a population
1256 /// initialized FROM the analytic posterior stays posterior-distributed
1257 /// under repeated sweeps. (Crossover only exchanges values between
1258 /// particles, so this — not prior-to-posterior transport — is the correct
1259 /// invariance check.)
1260 #[test]
1261 fn test_crossover_product_invariance() {
1262 let (y0, y1) = (1.0, -0.5);
1263 let model_fn = two_site_model(y0, y1);
1264 let mut rng = StdRng::seed_from_u64(77);
1265
1266 // Initialize each particle exactly from the product posterior.
1267 let post0 = Normal::new(y0 / 2.0, (0.5f64).sqrt()).unwrap();
1268 let post1 = Normal::new(y1 / 2.0, (0.5f64).sqrt()).unwrap();
1269 let n = 300;
1270 let mut particles: Vec<Particle> = (0..n)
1271 .map(|_| {
1272 let mut base = Trace::default();
1273 base.insert_choice(
1274 addr!("x", 0),
1275 crate::runtime::trace::ChoiceValue::F64(post0.sample(&mut rng)),
1276 0.0,
1277 );
1278 base.insert_choice(
1279 addr!("x", 1),
1280 crate::runtime::trace::ChoiceValue::F64(post1.sample(&mut rng)),
1281 0.0,
1282 );
1283 let (_, scored) = run(
1284 ScoreGivenTrace {
1285 base,
1286 trace: Trace::default(),
1287 },
1288 model_fn(),
1289 );
1290 Particle {
1291 trace: scored,
1292 weight: 1.0 / n as f64,
1293 log_weight: -(n as f64).ln(),
1294 }
1295 })
1296 .collect();
1297
1298 let mut kernel = CrossoverKernel {
1299 n_pairs: 150,
1300 mask: random_site_mask(),
1301 };
1302 for _ in 0..40 {
1303 PopulationKernel::<(f64, f64)>::sweep(
1304 &mut kernel,
1305 &mut rng,
1306 &mut particles,
1307 &model_fn,
1308 1.0,
1309 );
1310 }
1311
1312 for (i, target_mean) in [(0usize, y0 / 2.0), (1usize, y1 / 2.0)] {
1313 let xs: Vec<f64> = particles
1314 .iter()
1315 .map(|p| p.trace.get_f64(&addr!("x", i)).unwrap())
1316 .collect();
1317 let mean: f64 = xs.iter().sum::<f64>() / xs.len() as f64;
1318 let var: f64 = xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / xs.len() as f64;
1319 assert!(
1320 (mean - target_mean).abs() < 0.15,
1321 "site {} marginal mean {} drifted from posterior {}",
1322 i,
1323 mean,
1324 target_mean
1325 );
1326 assert!(
1327 (var - 0.5).abs() < 0.15,
1328 "site {} marginal var {} drifted from posterior 0.5",
1329 i,
1330 var
1331 );
1332 }
1333 }
1334
1335 /// EA-as-PPL F4 contract (W): a sweep never touches particle weights.
1336 #[test]
1337 fn test_crossover_preserves_uniform_weights() {
1338 let model_fn = two_site_model(1.0, -0.5);
1339 let mut rng = StdRng::seed_from_u64(88);
1340 let mut particles = smc_prior_particles(&mut rng, 30, &model_fn);
1341 let before: Vec<(f64, f64)> = particles.iter().map(|p| (p.weight, p.log_weight)).collect();
1342
1343 let mut kernel = CrossoverKernel {
1344 n_pairs: 60,
1345 mask: random_site_mask(),
1346 };
1347 PopulationKernel::<(f64, f64)>::sweep(
1348 &mut kernel,
1349 &mut rng,
1350 &mut particles,
1351 &model_fn,
1352 0.7,
1353 );
1354
1355 let after: Vec<(f64, f64)> = particles.iter().map(|p| (p.weight, p.log_weight)).collect();
1356 assert_eq!(before, after, "crossover sweep modified particle weights");
1357 }
1358
1359 /// EA-as-PPL F4 contract (E) / FG-58: an invariant kernel must not shift
1360 /// the log-evidence estimate. Both runs are compared to the analytic
1361 /// marginal likelihood of the conjugate model.
1362 #[test]
1363 fn test_crossover_evidence_noncorruption() {
1364 let (y0, y1) = (1.0, -0.5);
1365 let model_fn = two_site_model(y0, y1);
1366 // Analytic: y_i ~ N(0, sqrt(1² + 1²)) independently.
1367 let marg = Normal::new(0.0, (2.0f64).sqrt()).unwrap();
1368 let analytic = marg.log_prob(&y0) + marg.log_prob(&y1);
1369
1370 let config = || SMCConfig {
1371 resampling_method: ResamplingMethod::Systematic,
1372 ess_threshold: 0.7,
1373 rejuvenation_steps: 2,
1374 };
1375 let mut rng = StdRng::seed_from_u64(99);
1376 let plain = adaptive_smc(&mut rng, 600, &model_fn, config());
1377 let mut kernel = CrossoverKernel {
1378 n_pairs: 300,
1379 mask: random_site_mask(),
1380 };
1381 let crossed = adaptive_smc_with_kernel(&mut rng, 600, &model_fn, config(), &mut kernel);
1382
1383 assert!(
1384 (plain.log_evidence - analytic).abs() < 0.25,
1385 "NoKernel evidence {} vs analytic {}",
1386 plain.log_evidence,
1387 analytic
1388 );
1389 assert!(
1390 (crossed.log_evidence - analytic).abs() < 0.25,
1391 "CrossoverKernel evidence {} vs analytic {}",
1392 crossed.log_evidence,
1393 analytic
1394 );
1395 }
1396
1397 /// EA-as-PPL F4: swaps that leave the target's support must be rejected.
1398 /// The constraint couples the two sites (x0 + x1 ≤ 1), so a crossover swap
1399 /// CAN violate it — the re-scored `-∞` density must reject the move.
1400 #[test]
1401 fn test_crossover_support_truncation() {
1402 let model_fn = || {
1403 sample(addr!("x", 0), Normal::new(0.0, 1.0).unwrap()).and_then(|x0| {
1404 sample(addr!("x", 1), Normal::new(0.0, 1.0).unwrap()).and_then(move |x1| {
1405 crate::core::model::guard(x0 + x1 <= 1.0).map(move |_| (x0, x1))
1406 })
1407 })
1408 };
1409 let mut rng = StdRng::seed_from_u64(111);
1410
1411 // Build a valid population by rejection from the prior.
1412 let n = 60;
1413 let mut particles = Vec::with_capacity(n);
1414 while particles.len() < n {
1415 let (_, t) = run(
1416 PriorHandler {
1417 rng: &mut rng,
1418 trace: Trace::default(),
1419 },
1420 model_fn(),
1421 );
1422 if t.total_log_weight().is_finite() {
1423 particles.push(Particle {
1424 trace: t,
1425 weight: 1.0 / n as f64,
1426 log_weight: -(n as f64).ln(),
1427 });
1428 }
1429 }
1430
1431 let mut kernel = CrossoverKernel {
1432 n_pairs: 120,
1433 // Swap only site 0 — guaranteed to threaten the joint constraint.
1434 mask: Box::new(|_: &Trace, _: &Trace, _: &mut dyn rand::RngCore| vec![addr!("x", 0)]),
1435 };
1436 for _ in 0..30 {
1437 PopulationKernel::<(f64, f64)>::sweep(
1438 &mut kernel,
1439 &mut rng,
1440 &mut particles,
1441 &model_fn,
1442 1.0,
1443 );
1444 for p in &particles {
1445 let x0 = p.trace.get_f64(&addr!("x", 0)).unwrap();
1446 let x1 = p.trace.get_f64(&addr!("x", 1)).unwrap();
1447 assert!(
1448 x0 + x1 <= 1.0 + 1e-12,
1449 "accepted crossover left the truncated support: {} + {} > 1",
1450 x0,
1451 x1
1452 );
1453 }
1454 }
1455 }
1456
1457 /// EA-as-PPL F5: decode returns exactly the value implied by the trace,
1458 /// decoded weights sum to 1, and the fallible variant rejects a foreign
1459 /// trace.
1460 #[test]
1461 fn test_decode_fidelity() {
1462 let model_fn = || {
1463 sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
1464 observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.5).map(move |_| mu)
1465 })
1466 };
1467 let mut rng = StdRng::seed_from_u64(123);
1468 let particles = smc_prior_particles(&mut rng, 20, model_fn);
1469 for p in &particles {
1470 let decoded = decode_particle(p, model_fn);
1471 assert_eq!(decoded, p.trace.get_f64(&addr!("mu")).unwrap());
1472 assert_eq!(try_decode_particle(p, model_fn).unwrap(), decoded);
1473 }
1474 let decoded = decode_particles(&particles, model_fn);
1475 let total: f64 = decoded.iter().map(|(_, w)| w).sum();
1476 assert!((total - 1.0).abs() < 1e-9);
1477
1478 // A trace missing the model's site must fail the fallible decode.
1479 let foreign = Particle {
1480 trace: Trace::default(),
1481 weight: 1.0,
1482 log_weight: 0.0,
1483 };
1484 assert!(try_decode_particle(&foreign, model_fn).is_err());
1485 }
1486
1487 /// EA-as-PPL F5 + EV-16 end-to-end: the fugue-evo conjugate "fitness as
1488 /// likelihood" target — prior N(0, 2²), factor −½(x−3)² — reproduced
1489 /// through `adaptive_smc_with_kernel` + `decode_particles`: posterior mean
1490 /// 2.4 ± 0.15, variance 0.8 ± 0.2. This is the readout path fugue-evo's
1491 /// rebuilt EvolutionarySMC uses in place of cached genome/fitness fields.
1492 #[test]
1493 fn test_decode_weighted_mean() {
1494 let model_fn = || {
1495 sample(addr!("gene", 0), Normal::new(0.0, 2.0).unwrap()).and_then(|x| {
1496 crate::core::model::factor(-0.5 * (x - 3.0) * (x - 3.0)).map(move |_| x)
1497 })
1498 };
1499 let mut rng = StdRng::seed_from_u64(2024);
1500 let config = SMCConfig {
1501 resampling_method: ResamplingMethod::Systematic,
1502 ess_threshold: 0.7,
1503 rejuvenation_steps: 3,
1504 };
1505 let mut kernel = CrossoverKernel {
1506 n_pairs: 200,
1507 mask: Box::new(|_: &Trace, _: &Trace, _: &mut dyn rand::RngCore| {
1508 vec![addr!("gene", 0)]
1509 }),
1510 };
1511 let result = adaptive_smc_with_kernel(&mut rng, 800, model_fn, config, &mut kernel);
1512
1513 let decoded = decode_particles(&result, model_fn);
1514 let mean: f64 = decoded.iter().map(|(x, w)| x * w).sum();
1515 let var: f64 = decoded.iter().map(|(x, w)| w * (x - mean).powi(2)).sum();
1516 assert!(
1517 (mean - 2.4).abs() < 0.15,
1518 "EV-16 posterior mean {} vs analytic 2.4",
1519 mean
1520 );
1521 assert!(
1522 (var - 0.8).abs() < 0.2,
1523 "EV-16 posterior variance {} vs analytic 0.8",
1524 var
1525 );
1526 }
1527
1528 #[test]
1529 fn adaptive_smc_runs_with_small_config() {
1530 let model_fn = || {
1531 sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
1532 observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.5).map(move |_| mu)
1533 })
1534 };
1535 let mut rng = StdRng::seed_from_u64(2);
1536 let config = SMCConfig {
1537 resampling_method: ResamplingMethod::Systematic,
1538 ess_threshold: 0.5,
1539 rejuvenation_steps: 1,
1540 };
1541 let particles = adaptive_smc(&mut rng, 5, model_fn, config);
1542 assert_eq!(particles.len(), 5);
1543 // Weights normalized
1544 let sum_w: f64 = particles.iter().map(|p| p.weight).sum();
1545 assert!((sum_w - 1.0).abs() < 1e-9);
1546 }
1547}