Skip to main content

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(&degenerate_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}