Skip to main content

fugue/inference/
mh.rs

1//! Metropolis-Hastings MCMC with adaptive tuning and single-site updates.
2//!
3//! This module implements the Metropolis-Hastings algorithm, a fundamental MCMC method
4//! for sampling from posterior distributions. The implementation features:
5//!
6//! - **Adaptive scaling**: Automatically tunes proposal step sizes to achieve target acceptance rates
7//! - **Single-site updates**: Updates one random variable at a time for better mixing
8//! - **Type-safe proposals**: Preserves original types (bool, u64, usize, etc.) during proposals
9//! - **Type-aware proposals**: Uses ProposalStrategy traits based on value types
10//! - **Correct Hastings corrections**: asymmetric proposals contribute their
11//!   `q(x|x') − q(x'|x)` term to the acceptance ratio (FG-02, FG-10)
12//!
13//! ## Proposal selection (FG-42)
14//!
15//! Proposal kinds are chosen from the *distribution's actual support*, not from
16//! substrings of the address name (the old `sigma`/`scale`/`p`/`beta` heuristics
17//! could, e.g., trap an unbounded parameter named `slope` in `[0,1]` and break
18//! ergodicity). The rules are:
19//!
20//! - **`f64`**: default to a symmetric **Gaussian** random walk. Out-of-support
21//!   proposals simply receive a `−inf` joint density and are rejected, so
22//!   ergodicity is preserved. A site is routed to a **log-space** walk (with the
23//!   exact Jacobian/Hastings correction) only when its current value is positive
24//!   *and* the site's prior density at a negative probe value is `−inf` — i.e.
25//!   the support is genuinely positive. A **reflected** `[a,b]` walk is used only
26//!   when explicitly requested per address.
27//! - **`usize` (categorical)**: propose by resampling from the site's **prior**
28//!   distribution. With `q = prior` the Hastings terms cancel the prior in the
29//!   target, so acceptance reduces to the likelihood ratio, and the proposal can
30//!   never miss the support (FG-10).
31//! - **`u64` (counts)**: a symmetric reflected discrete walk (FG-41).
32//! - **`bool`**: a deterministic flip (symmetric).
33//!
34//! Callers can override the `f64` proposal for any address via
35//! [`adaptive_mcmc_chain_with_overrides`] using [`SiteProposal`].
36//!
37//! ## Structure-varying (trans-dimensional) models (FG-20 / FG-21)
38//!
39//! Models whose set of sample addresses depends on a sampled value (e.g.
40//! `b ~ Bernoulli; if b { x ~ … }`) are handled without panicking. A proposal
41//! that opens a new branch samples the fresh sites from their prior and treats
42//! the change as a reversible-jump birth; a proposal that closes a branch treats
43//! the vanished sites as a death. Both the fresh/vanished sites' prior densities
44//! (as prior-proposal q terms) and the change in the single-site selection
45//! probability (`ln|sites(current)| − ln|sites(proposed)|`) enter the acceptance
46//! ratio, so the chain leaves the correct trans-dimensional posterior invariant
47//! for prior-proposed structure changes rather than silently biasing it. For
48//! fixed-structure models every one of these corrections is identically zero, so
49//! behavior is unchanged.
50//!
51//! ## Algorithm Overview
52//!
53//! The Metropolis-Hastings algorithm generates correlated samples from the posterior by:
54//! 1. Proposing a new state by modifying the current state
55//! 2. Computing the acceptance probability using the ratio of posterior densities
56//!    plus the proposal (Hastings) correction
57//! 3. Accepting or rejecting the proposal based on this probability
58//! 4. Repeating to generate a Markov chain that converges to the posterior
59//!
60//! ## Cost model (FG-11)
61//!
62//! Lightweight trace-based single-site MCMC is inherently **O(model-size)** per
63//! transition: scoring a proposal requires re-executing the whole model to
64//! recompute the log-density contributions that depend on the touched site. This
65//! implementation removes the *redundant* work (it re-executes the model exactly
66//! once per step — see [`adaptive_mcmc_chain`] — caches the current state's
67//! score and the site list across iterations, and avoids the extra trace clones),
68//! but the per-transition cost still scales with the number of sites. Models with
69//! very many latent variables should prefer a gradient-based kernel.
70//!
71//! ## Adaptive Tuning
72//!
73//! Good MCMC performance requires well-tuned proposal distributions. This implementation
74//! automatically adapts proposal scales during warmup to achieve approximately 44%
75//! acceptance rate (optimal for random-walk Metropolis on continuous distributions),
76//! then **freezes** the scales for the sampling phase so the recorded draws come from a
77//! single fixed transition kernel (FG-57).
78//!
79//! # Examples
80//!
81//! ```rust
82//! use fugue::*;
83//! use rand::rngs::StdRng;
84//! use rand::SeedableRng;
85//!
86//! // Define a simple Bayesian model
87//! let model_fn = || {
88//!     sample(addr!("mu"), Normal::new(0.0, 2.0).unwrap())
89//!         .bind(|mu| observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 2.5))
90//! };
91//!
92//! // Run adaptive MCMC (small numbers for testing)
93//! let mut rng = StdRng::seed_from_u64(42);
94//! let samples = adaptive_mcmc_chain(
95//!     &mut rng,
96//!     model_fn,
97//!     50,  // Number of samples (small for test)
98//!     10,  // Burn-in period
99//! );
100//!
101//! // Extract parameter estimates
102//! let mu_samples: Vec<f64> = samples.iter()
103//!     .filter_map(|(_, trace)| trace.choices.get(&addr!("mu")))
104//!     .filter_map(|choice| match choice.value {
105//!         ChoiceValue::F64(mu) => Some(mu),
106//!         _ => None,
107//!     })
108//!     .collect();
109//!
110//! assert!(!mu_samples.is_empty());
111//! ```
112use crate::core::address::Address;
113use crate::core::distribution::Distribution;
114use crate::core::model::Model;
115use crate::inference::mcmc_utils::DiminishingAdaptation;
116use crate::runtime::handler::{run, Handler};
117use crate::runtime::interpreters::{score_given_trace_reconciled, PriorHandler, ScoreGivenTrace};
118use crate::runtime::trace::{Choice, ChoiceValue, Trace};
119use rand::{Rng, RngCore};
120use std::collections::HashMap;
121
122/// Negative probe value used to detect positive-support `f64` sites (FG-42).
123/// A site whose prior density is `−inf` here (and whose current value is
124/// positive) is treated as positively constrained and given a log-space walk.
125const NEG_SUPPORT_PROBE: f64 = -1.0;
126
127/// Standard-normal draw via Box-Muller (shared by the random-walk proposals).
128fn gaussian_z(rng: &mut dyn RngCore) -> f64 {
129    let u1: f64 = rng.gen::<f64>().max(1e-10); // avoid ln(0)
130    let u2: f64 = rng.gen();
131    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
132}
133
134/// Log-density of `Normal(mean, sd)` at `x` (used for the log-space Jacobian).
135fn normal_logpdf(x: f64, mean: f64, sd: f64) -> f64 {
136    let z = (x - mean) / sd;
137    -0.5 * z * z - sd.ln() - 0.5 * (2.0 * std::f64::consts::PI).ln()
138}
139
140/// User-facing per-address proposal override for `f64` sites (FG-42).
141///
142/// The samplers pick a sensible proposal automatically from each site's support,
143/// but callers can force a specific kind via
144/// [`adaptive_mcmc_chain_with_overrides`].
145#[derive(Clone, Copy, Debug, PartialEq)]
146pub enum SiteProposal {
147    /// Symmetric Gaussian random walk (default for unconstrained `f64`).
148    Gaussian,
149    /// Log-space random walk with the exact Jacobian/Hastings correction, for
150    /// positive-support parameters (scales, rates, …). FG-02.
151    LogSpace,
152    /// Reflected random walk confined to `[lower, upper]` (symmetric).
153    Reflect {
154        /// Inclusive lower bound.
155        lower: f64,
156        /// Inclusive upper bound.
157        upper: f64,
158    },
159    /// Independence proposal that resamples the site from its prior. FG-10.
160    PriorResample,
161}
162
163/// Trait for distribution-aware proposal strategies.
164///
165/// This enables more intelligent proposals that take advantage of the distribution
166/// structure rather than using generic random walks.
167pub trait ProposalStrategy<T> {
168    /// Generate a proposal given the current value and scale.
169    fn propose(&self, current: T, scale: f64, rng: &mut dyn RngCore) -> T;
170
171    /// Log-density `log q(to | from)` of proposing `to` from `from` at the given
172    /// `scale`. Defaults to `0` for symmetric proposals (the constant cancels in
173    /// the Hastings ratio); asymmetric proposals override it.
174    fn log_proposal_prob(&self, from: T, to: T, scale: f64) -> f64 {
175        let _ = (from, to, scale);
176        0.0 // Default: symmetric proposal
177    }
178}
179
180/// Gaussian random walk proposal for continuous distributions (symmetric).
181pub struct GaussianWalkProposal;
182
183impl ProposalStrategy<f64> for GaussianWalkProposal {
184    fn propose(&self, current: f64, scale: f64, rng: &mut dyn RngCore) -> f64 {
185        current + scale * gaussian_z(rng)
186    }
187}
188
189/// Log-space random walk proposal for positive-constrained continuous parameters.
190///
191/// Proposes `x' = exp(ln x + scale·z)`, which keeps `x'` strictly positive. This
192/// map is **asymmetric** in the original space: the induced density is
193/// `q(x'|x) = N(ln x'; ln x, scale²) / x'`. Its [`log_proposal_prob`] returns
194/// exactly that log-density, so the acceptance ratio picks up the Jacobian term
195/// `+(ln x' − ln x)` (FG-02). Omitting it makes the chain target `π(x)/x` instead
196/// of `π(x)`.
197///
198/// [`log_proposal_prob`]: ProposalStrategy::log_proposal_prob
199pub struct LogSpaceWalkProposal;
200
201impl ProposalStrategy<f64> for LogSpaceWalkProposal {
202    fn propose(&self, current: f64, scale: f64, rng: &mut dyn RngCore) -> f64 {
203        if current <= 0.0 {
204            // Out of the proposal's domain; nudge to the smallest positive value.
205            return f64::MIN_POSITIVE;
206        }
207        let z = gaussian_z(rng);
208        let proposed = (current.ln() + scale * z).exp();
209        if proposed.is_finite() {
210            proposed.max(f64::MIN_POSITIVE)
211        } else {
212            // Extreme tail; a huge finite value will score to −inf and reject.
213            f64::MAX
214        }
215    }
216
217    fn log_proposal_prob(&self, from: f64, to: f64, scale: f64) -> f64 {
218        if from <= 0.0 || to <= 0.0 {
219            return 0.0;
220        }
221        // q(to|from) = N(ln to; ln from, scale) · |d ln to / d to| = N(...) / to.
222        normal_logpdf(to.ln(), from.ln(), scale) - to.ln()
223    }
224}
225
226/// Reflection-based proposal for bounded continuous distributions (symmetric).
227///
228/// Reflects a Gaussian step off the boundaries to stay within `[lower, upper]`.
229/// Reflection preserves symmetry, so no Hastings correction is needed.
230pub struct ReflectionWalkProposal {
231    /// Lower bound (inclusive)
232    pub lower_bound: f64,
233    /// Upper bound (inclusive)
234    pub upper_bound: f64,
235}
236
237impl ProposalStrategy<f64> for ReflectionWalkProposal {
238    fn propose(&self, current: f64, scale: f64, rng: &mut dyn RngCore) -> f64 {
239        let mut proposed = current + scale * gaussian_z(rng);
240
241        let range = self.upper_bound - self.lower_bound;
242        if range <= 0.0 {
243            return current; // Invalid bounds, return current
244        }
245
246        // Reflect off boundaries until within bounds.
247        while proposed < self.lower_bound || proposed > self.upper_bound {
248            if proposed < self.lower_bound {
249                proposed = 2.0 * self.lower_bound - proposed;
250            }
251            if proposed > self.upper_bound {
252                proposed = 2.0 * self.upper_bound - proposed;
253            }
254        }
255
256        proposed.clamp(self.lower_bound, self.upper_bound)
257    }
258}
259
260/// Flip proposal for boolean distributions (symmetric).
261pub struct FlipProposal;
262
263impl ProposalStrategy<bool> for FlipProposal {
264    fn propose(&self, current: bool, _scale: f64, _rng: &mut dyn RngCore) -> bool {
265        // Deterministic flip: q(!x|x) = q(x|!x) = 1, so the proposal is symmetric
266        // and mixes maximally for a single binary site.
267        !current
268    }
269}
270
271/// Discrete random walk proposal for non-negative count distributions.
272///
273/// Draws `delta = round(scale·z)` from a symmetric integer distribution and
274/// reflects at the boundary about `−1/2` (`k → −k−1` when `x + delta < 0`).
275///
276/// FG-41: plain `|x + delta|` (reflection about `0`) is **not** symmetric at the
277/// boundary — `0` is a fixed point of negation, so it has no reflection partner
278/// and moves involving state `0` are mis-weighted by a factor of 2
279/// (`q(y|0) = 2·q(0|y)`). Reflecting about `−1/2` instead makes the map a clean
280/// two-to-one folding with no fixed point, giving an exactly symmetric kernel
281/// (`q(a|b) = q(b|a)` everywhere, including at `0`), so no Hastings correction is
282/// needed.
283pub struct DiscreteWalkProposal;
284
285impl ProposalStrategy<u64> for DiscreteWalkProposal {
286    fn propose(&self, current: u64, scale: f64, rng: &mut dyn RngCore) -> u64 {
287        let delta = (scale * gaussian_z(rng)).round() as i64;
288        let k = current as i64 + delta;
289        if k >= 0 {
290            k as u64
291        } else {
292            (-k - 1) as u64 // reflect about −1/2 (symmetric)
293        }
294    }
295}
296
297/// Handler that performs one single-site proposal *inside* a single model run.
298///
299/// All sites except `target` are replayed from `base` and re-scored under the
300/// current model (their densities may change when `target` changes, e.g. in a
301/// hierarchical model). The `target` site is proposed according to its value
302/// type and support, freshly scored, and its forward/reverse proposal
303/// log-densities are written to `log_q_forward` / `log_q_reverse` for the
304/// acceptance ratio. Producing a fully, freshly-scored proposal trace in one run
305/// is what lets the driver return correct accumulators (FG-40) and avoid the
306/// extra current-scoring run (FG-11/FG-12).
307///
308/// ## Structure-varying (trans-dimensional) proposals (FG-20 / FG-21)
309///
310/// If `target`'s new value opens a branch that requires an address absent from
311/// `base`, that address is sampled fresh from its prior (rather than panicking as
312/// raw `ScoreGivenTrace` would). This is treated as a **reversible-jump birth
313/// with the prior as the proposal**: the fresh site's prior log-density is added
314/// to `log_q_forward`, so it cancels the same `log_prior` term the site
315/// contributes to the proposal's joint and the acceptance ratio reduces to the
316/// correct RJMCMC form (Jacobian `= 1`). Symmetrically, an address present in
317/// `base` that the proposed structure no longer visits (a **death**) has its
318/// prior log-density added to `log_q_reverse` by [`propose_and_score`], canceling
319/// its contribution to the current state's joint. Together these make single-site
320/// MH leave the correct (trans-dimensional) posterior invariant for models whose
321/// fresh sub-structure is sampled from the prior — e.g. `b ~ Bernoulli; if b { x ~
322/// … }` — instead of silently biasing it. The sampler never panics on a
323/// structure-varying model; it continues with the RJMCMC-corrected ratio.
324struct SingleSiteProposalHandler<'a, R: RngCore> {
325    rng: &'a mut R,
326    base: &'a Trace,
327    target: &'a Address,
328    scale: f64,
329    overrides: &'a HashMap<Address, SiteProposal>,
330    kind_cache: &'a mut HashMap<Address, SiteProposal>,
331    log_q_forward: &'a mut f64,
332    log_q_reverse: &'a mut f64,
333    trace: Trace,
334}
335
336impl<'a, R: RngCore> SingleSiteProposalHandler<'a, R> {
337    /// Decide the `f64` proposal kind for the target site (FG-42), caching the
338    /// probe result so support detection happens at most once per address.
339    fn f64_kind(
340        &mut self,
341        addr: &Address,
342        current: f64,
343        dist: &dyn Distribution<f64>,
344    ) -> SiteProposal {
345        if let Some(&k) = self.overrides.get(addr) {
346            return k;
347        }
348        if let Some(&k) = self.kind_cache.get(addr) {
349            return k;
350        }
351        let kind = if current > 0.0 && !dist.log_prob(&NEG_SUPPORT_PROBE).is_finite() {
352            SiteProposal::LogSpace
353        } else {
354            SiteProposal::Gaussian
355        };
356        self.kind_cache.insert(addr.clone(), kind);
357        kind
358    }
359}
360
361impl<'a, R: RngCore> Handler for SingleSiteProposalHandler<'a, R> {
362    fn on_sample_f64(&mut self, addr: &Address, dist: &dyn Distribution<f64>) -> f64 {
363        if addr == self.target {
364            let current = self
365                .base
366                .get_f64(addr)
367                .unwrap_or_else(|| dist.sample(self.rng));
368            let kind = self.f64_kind(addr, current, dist);
369            let (proposed, lqf, lqr) = match kind {
370                SiteProposal::Gaussian => {
371                    let s = GaussianWalkProposal;
372                    let p = s.propose(current, self.scale, self.rng);
373                    (
374                        p,
375                        s.log_proposal_prob(current, p, self.scale),
376                        s.log_proposal_prob(p, current, self.scale),
377                    )
378                }
379                SiteProposal::LogSpace => {
380                    let s = LogSpaceWalkProposal;
381                    let p = s.propose(current, self.scale, self.rng);
382                    (
383                        p,
384                        s.log_proposal_prob(current, p, self.scale),
385                        s.log_proposal_prob(p, current, self.scale),
386                    )
387                }
388                SiteProposal::Reflect { lower, upper } => {
389                    let s = ReflectionWalkProposal {
390                        lower_bound: lower,
391                        upper_bound: upper,
392                    };
393                    let p = s.propose(current, self.scale, self.rng);
394                    (
395                        p,
396                        s.log_proposal_prob(current, p, self.scale),
397                        s.log_proposal_prob(p, current, self.scale),
398                    )
399                }
400                SiteProposal::PriorResample => {
401                    let p = dist.sample(self.rng);
402                    (p, dist.log_prob(&p), dist.log_prob(&current))
403                }
404            };
405            // Accumulate (`+=`, not `=`) so a fresh dimension born earlier in the
406            // execution order (its prior term already added to `log_q_forward`)
407            // is not clobbered by the target's own proposal density.
408            *self.log_q_forward += lqf;
409            *self.log_q_reverse += lqr;
410            let lp = dist.log_prob(&proposed);
411            self.trace.log_prior += lp;
412            self.trace.choices.insert(
413                addr.clone(),
414                Choice {
415                    addr: addr.clone(),
416                    value: ChoiceValue::F64(proposed),
417                    logp: lp,
418                },
419            );
420            proposed
421        } else {
422            let (x, born) = match self.base.get_f64(addr) {
423                Some(v) => (v, false),
424                None => (dist.sample(self.rng), true),
425            };
426            let lp = dist.log_prob(&x);
427            if born {
428                // RJMCMC birth from the prior: cancel this fresh site's log_prior.
429                *self.log_q_forward += lp;
430            }
431            self.trace.log_prior += lp;
432            self.trace.choices.insert(
433                addr.clone(),
434                Choice {
435                    addr: addr.clone(),
436                    value: ChoiceValue::F64(x),
437                    logp: lp,
438                },
439            );
440            x
441        }
442    }
443
444    fn on_sample_bool(&mut self, addr: &Address, dist: &dyn Distribution<bool>) -> bool {
445        let mut born = false;
446        let x = if addr == self.target {
447            let current = self
448                .base
449                .get_bool(addr)
450                .unwrap_or_else(|| dist.sample(self.rng));
451            // Symmetric deterministic flip: contributes 0 to both q terms (leave
452            // any born/died structural corrections already accumulated intact).
453            FlipProposal.propose(current, self.scale, self.rng)
454        } else {
455            match self.base.get_bool(addr) {
456                Some(v) => v,
457                None => {
458                    born = true;
459                    dist.sample(self.rng)
460                }
461            }
462        };
463        let lp = dist.log_prob(&x);
464        if born {
465            // RJMCMC birth from the prior: cancel this fresh site's log_prior.
466            *self.log_q_forward += lp;
467        }
468        self.trace.log_prior += lp;
469        self.trace.choices.insert(
470            addr.clone(),
471            Choice {
472                addr: addr.clone(),
473                value: ChoiceValue::Bool(x),
474                logp: lp,
475            },
476        );
477        x
478    }
479
480    fn on_sample_u64(&mut self, addr: &Address, dist: &dyn Distribution<u64>) -> u64 {
481        let mut born = false;
482        let x = if addr == self.target {
483            let current = self
484                .base
485                .get_u64(addr)
486                .unwrap_or_else(|| dist.sample(self.rng));
487            // Symmetric reflected discrete walk (FG-41): contributes 0 to both q
488            // terms (leave any born/died structural corrections intact).
489            DiscreteWalkProposal.propose(current, self.scale, self.rng)
490        } else {
491            match self.base.get_u64(addr) {
492                Some(v) => v,
493                None => {
494                    born = true;
495                    dist.sample(self.rng)
496                }
497            }
498        };
499        let lp = dist.log_prob(&x);
500        if born {
501            // RJMCMC birth from the prior: cancel this fresh site's log_prior.
502            *self.log_q_forward += lp;
503        }
504        self.trace.log_prior += lp;
505        self.trace.choices.insert(
506            addr.clone(),
507            Choice {
508                addr: addr.clone(),
509                value: ChoiceValue::U64(x),
510                logp: lp,
511            },
512        );
513        x
514    }
515
516    fn on_sample_usize(&mut self, addr: &Address, dist: &dyn Distribution<usize>) -> usize {
517        let mut born = false;
518        let x = if addr == self.target {
519            let current = self
520                .base
521                .get_usize(addr)
522                .unwrap_or_else(|| dist.sample(self.rng));
523            // FG-10: resample from the site's prior. With q = prior the Hastings
524            // terms cancel the prior in the target, so acceptance reduces to the
525            // likelihood ratio and no category can ever be missed.
526            let proposed = dist.sample(self.rng);
527            // `+=` so a born fresh dimension's prior term is preserved.
528            *self.log_q_forward += dist.log_prob(&proposed);
529            *self.log_q_reverse += dist.log_prob(&current);
530            proposed
531        } else {
532            match self.base.get_usize(addr) {
533                Some(v) => v,
534                None => {
535                    born = true;
536                    dist.sample(self.rng)
537                }
538            }
539        };
540        let lp = dist.log_prob(&x);
541        if born {
542            // RJMCMC birth from the prior: cancel this fresh site's log_prior.
543            *self.log_q_forward += lp;
544        }
545        self.trace.log_prior += lp;
546        self.trace.choices.insert(
547            addr.clone(),
548            Choice {
549                addr: addr.clone(),
550                value: ChoiceValue::Usize(x),
551                logp: lp,
552            },
553        );
554        x
555    }
556
557    fn on_sample_i64(&mut self, addr: &Address, dist: &dyn Distribution<i64>) -> i64 {
558        let mut born = false;
559        let x = if addr == self.target {
560            let current = self
561                .base
562                .get_i64(addr)
563                .unwrap_or_else(|| dist.sample(self.rng));
564            // Symmetric integer random walk (no boundary to reflect at):
565            // contributes 0 to both q terms (leave born/died corrections intact).
566            let delta = (self.scale * gaussian_z(self.rng)).round() as i64;
567            current + delta
568        } else {
569            match self.base.get_i64(addr) {
570                Some(v) => v,
571                None => {
572                    born = true;
573                    dist.sample(self.rng)
574                }
575            }
576        };
577        let lp = dist.log_prob(&x);
578        if born {
579            // RJMCMC birth from the prior: cancel this fresh site's log_prior.
580            *self.log_q_forward += lp;
581        }
582        self.trace.log_prior += lp;
583        self.trace.choices.insert(
584            addr.clone(),
585            Choice {
586                addr: addr.clone(),
587                value: ChoiceValue::I64(x),
588                logp: lp,
589            },
590        );
591        x
592    }
593
594    fn on_observe_f64(&mut self, _addr: &Address, dist: &dyn Distribution<f64>, value: f64) {
595        self.trace.log_likelihood += dist.log_prob(&value);
596    }
597    fn on_observe_bool(&mut self, _addr: &Address, dist: &dyn Distribution<bool>, value: bool) {
598        self.trace.log_likelihood += dist.log_prob(&value);
599    }
600    fn on_observe_u64(&mut self, _addr: &Address, dist: &dyn Distribution<u64>, value: u64) {
601        self.trace.log_likelihood += dist.log_prob(&value);
602    }
603    fn on_observe_usize(&mut self, _addr: &Address, dist: &dyn Distribution<usize>, value: usize) {
604        self.trace.log_likelihood += dist.log_prob(&value);
605    }
606    fn on_observe_i64(&mut self, _addr: &Address, dist: &dyn Distribution<i64>, value: i64) {
607        self.trace.log_likelihood += dist.log_prob(&value);
608    }
609
610    fn on_factor(&mut self, logw: f64) {
611        self.trace.log_factors += logw;
612    }
613
614    fn finish(self) -> Trace {
615        self.trace
616    }
617}
618
619/// Propose a new value at `target` and fully score the resulting trace in one
620/// model run. Returns `(model_result, proposed_trace, proposed_log_weight,
621/// log_q_forward, log_q_reverse)`.
622///
623/// `log_q_forward` accumulates the target's proposal density plus the prior
624/// density of every fresh dimension born by the proposal (the RJMCMC birth term).
625/// `log_q_reverse` accumulates the target's reverse density plus the prior
626/// density of every dimension that DIED — present in `current` but not visited by
627/// the proposed structure — which is the reverse-move birth term for those sites
628/// (FG-20 / FG-21). Together they make `log α = Δlog-joint + log q_reverse −
629/// log q_forward` the correct trans-dimensional acceptance ratio for
630/// prior-proposed structural changes.
631///
632/// The final `bool` reports whether the proposed trace's address SET differs from
633/// `current` (a birth and/or death occurred), so the chain driver can refresh its
634/// cached site list even when the site count is unchanged (e.g. a branch that
635/// swaps one address for another).
636#[allow(clippy::type_complexity)]
637pub(crate) fn propose_and_score<A, F, R>(
638    rng: &mut R,
639    model_fn: &F,
640    current: &Trace,
641    target: &Address,
642    scale: f64,
643    overrides: &HashMap<Address, SiteProposal>,
644    kind_cache: &mut HashMap<Address, SiteProposal>,
645) -> (A, Trace, f64, f64, f64, bool)
646where
647    F: Fn() -> Model<A>,
648    R: Rng,
649{
650    let mut lqf = 0.0;
651    let mut lqr = 0.0;
652    let (a, trace) = run(
653        SingleSiteProposalHandler {
654            rng,
655            base: current,
656            target,
657            scale,
658            overrides,
659            kind_cache,
660            log_q_forward: &mut lqf,
661            log_q_reverse: &mut lqr,
662            trace: Trace::default(),
663        },
664        model_fn(),
665    );
666    // Death correction: any address in `current` the proposal no longer visits is
667    // a dimension the reverse move would have to birth from its prior. Adding its
668    // stored prior log-density (the reverse-birth proposal density) to
669    // `log_q_reverse` cancels its contribution to `current`'s joint in the
670    // acceptance ratio, completing the RJMCMC dimension-matching (FG-20/FG-21).
671    let mut died = 0usize;
672    for (addr, choice) in &current.choices {
673        if !trace.choices.contains_key(addr) {
674            lqr += choice.logp;
675            died += 1;
676        }
677    }
678    // born = |proposed| − |current| + died  (|proposed| = |current| − died + born).
679    let born = trace.choices.len() + died - current.choices.len();
680    let structure_changed = born > 0 || died > 0;
681    let lw = trace.total_log_weight();
682    (a, trace, lw, lqf, lqr, structure_changed)
683}
684
685/// One cached single-site MH transition used by the chain driver.
686///
687/// The current state's log-weight (`current_lw`) and the ordered `sites` list are
688/// supplied by the caller and cached across iterations, so this performs exactly
689/// one model run (the proposal). Returns `Some((result, trace, log_weight))` on
690/// acceptance (a freshly-scored trace, FG-40) and `None` on rejection — the
691/// caller keeps its cached current state, so no extra model run happens on
692/// rejection (FG-12).
693///
694/// On acceptance the returned tuple's final `bool` flags whether the accepted
695/// move changed the model's address structure, so the driver can refresh its
696/// cached site list (FG-20/FG-21).
697#[allow(clippy::too_many_arguments)]
698fn single_site_mh_step<A, F, R>(
699    rng: &mut R,
700    model_fn: &F,
701    current: &Trace,
702    current_lw: f64,
703    sites: &[Address],
704    adaptation: &mut DiminishingAdaptation,
705    overrides: &HashMap<Address, SiteProposal>,
706    kind_cache: &mut HashMap<Address, SiteProposal>,
707    adapt: bool,
708) -> Option<(A, Trace, f64, bool)>
709where
710    F: Fn() -> Model<A>,
711    R: Rng,
712{
713    if sites.is_empty() {
714        return None;
715    }
716    let target = sites[rng.gen_range(0..sites.len())].clone();
717    let scale = adaptation.get_scale(&target);
718
719    let (a_prop, prop_trace, prop_lw, lqf, lqr, structure_changed) = propose_and_score(
720        rng, model_fn, current, &target, scale, overrides, kind_cache,
721    );
722
723    // log α = Δlog-joint + log q(x|x') − log q(x'|x) + dimension term.
724    //
725    // The single-site kernel picks the target uniformly among the *current*
726    // sites, so the forward move carries proposal factor 1/|sites(current)| and
727    // the reverse carries 1/|sites(proposed)|. For structure-varying proposals
728    // these differ, and the term `ln|sites(current)| − ln|sites(proposed)|`
729    // completes the RJMCMC dimension matching (FG-20/FG-21). For fixed-structure
730    // models the two site counts are equal and the term is exactly 0.
731    let dim_term = (sites.len() as f64).ln() - (prop_trace.choices.len() as f64).ln();
732    let log_alpha = prop_lw - current_lw + (lqr - lqf) + dim_term;
733    let accept = log_alpha >= 0.0 || rng.gen::<f64>() < log_alpha.exp();
734
735    if adapt {
736        adaptation.update(&target, accept);
737    }
738
739    if accept {
740        Some((a_prop, prop_trace, prop_lw, structure_changed))
741    } else {
742        None
743    }
744}
745
746/// Perform a single adaptive Metropolis-Hastings update step.
747///
748/// This function implements a single iteration of the MH algorithm with proper
749/// diminishing adaptation that preserves ergodicity. It randomly selects one site
750/// to update, proposes a new value using adaptive scaling, and accepts or rejects
751/// based on the Metropolis-Hastings criterion (including the proposal/Hastings
752/// correction for asymmetric proposals).
753///
754/// # Algorithm
755///
756/// 1. Score the current state once (reused on rejection — no redundant third
757///    model run, FG-12).
758/// 2. Randomly select a site and propose a new value using diminishing adaptive
759///    scaling, scoring the proposal in the same run (FG-11).
760/// 3. Accept with probability `min(1, exp(log α))` where
761///    `log α = Δlog-joint + q(x|x') − q(x'|x)` plus, for structure-varying
762///    proposals, the RJMCMC dimension term (0 for fixed-structure models).
763/// 4. Update adaptive scales using diminishing step sizes.
764///
765/// On acceptance the returned trace is freshly scored, so its
766/// `total_log_weight()` is correct (FG-40).
767///
768/// # Arguments
769///
770/// * `rng` - Random number generator
771/// * `model_fn` - Function that creates the model
772/// * `current` - Current trace (state of the Markov chain)
773/// * `adaptation` - Diminishing adaptation system (modified in-place)
774///
775/// # Returns
776///
777/// Tuple of (model_result, new_trace) after the MH step.
778///
779/// # Examples
780///
781/// ```rust
782/// use fugue::*;
783/// use rand::rngs::StdRng;
784/// use rand::SeedableRng;
785///
786/// // Set up initial state with simple model
787/// let model_fn = || sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap());
788///
789/// let mut rng = StdRng::seed_from_u64(42);
790/// let (_, initial_trace) = runtime::handler::run(
791///     PriorHandler { rng: &mut rng, trace: Trace::default() },
792///     model_fn()
793/// );
794///
795/// // Perform one MH step
796/// let mut adaptation = DiminishingAdaptation::new(0.44, 0.7);
797/// let (result, new_trace) = adaptive_single_site_mh(
798///     &mut rng,
799///     model_fn,
800///     &initial_trace,
801///     &mut adaptation,
802/// );
803/// assert!(new_trace.choices.len() > 0);
804/// ```
805pub fn adaptive_single_site_mh<A, R: Rng>(
806    rng: &mut R,
807    model_fn: impl Fn() -> Model<A>,
808    current: &Trace,
809    adaptation: &mut DiminishingAdaptation,
810) -> (A, Trace) {
811    let overrides: HashMap<Address, SiteProposal> = HashMap::new();
812    let mut kind_cache: HashMap<Address, SiteProposal> = HashMap::new();
813
814    if current.choices.is_empty() {
815        // No latent choices to update; just recover the model result.
816        let (a, _) = run(
817            ScoreGivenTrace {
818                base: current.clone(),
819                trace: Trace::default(),
820            },
821            model_fn(),
822        );
823        return (a, current.clone());
824    }
825
826    // Score the current state once. The model result `a_cur` is reused on
827    // rejection instead of re-executing the model a third time (FG-12).
828    let (a_cur, cur_scored) = run(
829        ScoreGivenTrace {
830            base: current.clone(),
831            trace: Trace::default(),
832        },
833        model_fn(),
834    );
835    let current_lw = cur_scored.total_log_weight();
836
837    let sites: Vec<Address> = current.choices.keys().cloned().collect();
838    let target = sites[rng.gen_range(0..sites.len())].clone();
839    let scale = adaptation.get_scale(&target);
840
841    let (a_prop, prop_trace, prop_lw, lqf, lqr, _structure_changed) = propose_and_score(
842        rng,
843        &model_fn,
844        current,
845        &target,
846        scale,
847        &overrides,
848        &mut kind_cache,
849    );
850
851    // Dimension term for structure-varying proposals (see `single_site_mh_step`);
852    // 0 for fixed-structure models.
853    let dim_term = (sites.len() as f64).ln() - (prop_trace.choices.len() as f64).ln();
854    let log_alpha = prop_lw - current_lw + (lqr - lqf) + dim_term;
855    let accept = log_alpha >= 0.0 || rng.gen::<f64>() < log_alpha.exp();
856    adaptation.update(&target, accept);
857
858    if accept {
859        (a_prop, prop_trace)
860    } else {
861        (a_cur, current.clone())
862    }
863}
864
865/// One block-regeneration Metropolis–Hastings transition.
866///
867/// Deletes the choices at every address in `block` from `current`, replays the
868/// model to fill them (fresh draws from the prior, via
869/// [`score_given_trace_reconciled`]), and accepts or rejects with the
870/// prior-cancelling acceptance ratio below. This generalizes single-site MH
871/// from one target address to an arbitrary address set S — the "selective
872/// resampling" primitive: proposal = regenerate the sub-trace at S from the
873/// prior conditioned on the untouched coordinates.
874///
875/// `beta` tempers the likelihood (`π_β(θ) ∝ p(θ)·p(y|θ)^β`; pass `1.0` for an
876/// untempered posterior move), which makes the move directly usable as an SMC
877/// block-rejuvenation kernel. Addresses in `block` absent from `current` are
878/// ignored; present-but-mismatched-type entries are treated as fresh by the
879/// reconciler. The returned trace is freshly scored (FG-40/FG-48), so
880/// `total_log_weight()` is valid.
881///
882/// # Acceptance ratio
883///
884/// ```text
885/// log α = (log_prior′ − log_prior) + β·(loglik′ − loglik) + log q_rev − log q_fwd
886/// log q_fwd = Σ_{a ∈ fresh}                    logp′(a)     (forward births from the prior)
887/// log q_rev = Σ_{a ∈ S present in current}     logp(a)
888///           + Σ_{a ∈ vanished}                 logp(a)      (death correction, FG-20/FG-21)
889/// ```
890///
891/// For a **fixed address structure** (`fresh = S`, `vanished = ∅`) the prior
892/// and proposal terms cancel exactly and this collapses to
893/// `log α = β·(loglik′ − loglik)` — the prior-cancellation property that makes
894/// block regeneration cheap to accept/reject.
895///
896/// Unlike the single-site kernels there is **no dimension-selection term**
897/// (`ln|sites(current)| − ln|sites(proposed)|`): that term corrects for picking
898/// the target uniformly among a state-dependent site set, whereas here the
899/// block S is fixed by the caller — the forward and reverse moves regenerate
900/// deterministic address sets whose proposal densities are fully accounted by
901/// the `log q` terms above.
902///
903/// If the reconciling replay reports an address conflict (the model visits the
904/// same address twice, FG-47), the move is treated as a rejection and the
905/// (re-scored) current state is returned.
906pub fn block_regeneration_mh<A, R: Rng>(
907    rng: &mut R,
908    model_fn: impl Fn() -> Model<A>,
909    current: &Trace,
910    block: &[Address],
911    beta: f64,
912) -> (A, Trace) {
913    // Score the current state once; also the state returned on rejection (FG-40).
914    let (a_cur, cur_scored) = run(
915        ScoreGivenTrace {
916            base: current.clone(),
917            trace: Trace::default(),
918        },
919        model_fn(),
920    );
921
922    // Delete the block, then replay: removed addresses the model re-visits are
923    // drawn fresh from their prior (reported in `fresh_addresses`); addresses
924    // the model no longer visits are `vanished_addresses`.
925    let mut base = current.clone();
926    for a in block {
927        base.choices.remove(a);
928    }
929    let (a_prop, prop, report) = match score_given_trace_reconciled(base, rng, model_fn()) {
930        Ok(t) => t,
931        Err(_) => return (a_cur, cur_scored), // reject on address conflict
932    };
933
934    let log_q_fwd: f64 = report
935        .fresh_addresses
936        .iter()
937        .filter_map(|a| prop.choices.get(a).map(|c| c.logp))
938        .sum();
939    // Block ∩ vanished = ∅ (vanished is computed against the block-deleted
940    // base), so the two reverse-birth sums never double-count a site.
941    let log_q_rev: f64 = block
942        .iter()
943        .filter_map(|a| current.choices.get(a).map(|c| c.logp))
944        .sum::<f64>()
945        + report
946            .vanished_addresses
947            .iter()
948            .filter_map(|a| current.choices.get(a).map(|c| c.logp))
949            .sum::<f64>();
950
951    let loglik = |t: &Trace| t.log_likelihood + t.log_factors;
952    let log_alpha = (prop.log_prior - cur_scored.log_prior)
953        + beta * (loglik(&prop) - loglik(&cur_scored))
954        + log_q_rev
955        - log_q_fwd;
956
957    if log_alpha >= 0.0 || rng.gen::<f64>() < log_alpha.exp() {
958        (a_prop, prop)
959    } else {
960        (a_cur, cur_scored)
961    }
962}
963
964/// Run an adaptive MCMC chain with automatic proposal tuning.
965///
966/// This is the main entry point for running Metropolis-Hastings MCMC on a
967/// probabilistic model. It automatically handles initialization, warmup/burn-in,
968/// and adaptive tuning of proposal scales to achieve good mixing.
969///
970/// # Algorithm
971///
972/// 1. Initialize the chain with a prior sample (correct, fresh accumulators).
973/// 2. Run the warmup period, discarding samples but adapting scales. The current
974///    state's score and the site list are cached across iterations, so each step
975///    re-executes the model exactly once (FG-11/FG-12).
976/// 3. **Freeze** the tuned scales and collect samples from the resulting fixed
977///    transition kernel (FG-57).
978/// 4. Return the post-warmup samples, each carrying a freshly-scored trace (FG-40).
979///
980/// Use [`adaptive_mcmc_chain_with_overrides`] to force specific proposals per
981/// address.
982///
983/// # Arguments
984///
985/// * `rng` - Random number generator
986/// * `model_fn` - Function that creates the model (should be the same each time)
987/// * `n_samples` - Number of post-warmup samples to collect
988/// * `n_warmup` - Number of warmup/burn-in iterations (not returned)
989///
990/// # Returns
991///
992/// Vector of (model_result, trace) pairs from the post-warmup sampling.
993///
994/// # Examples
995///
996/// ```rust
997/// use fugue::*;
998/// use rand::rngs::StdRng;
999/// use rand::SeedableRng;
1000///
1001/// // Very simple model for testing
1002/// let model_fn = || {
1003///     sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap())
1004/// };
1005///
1006/// let mut rng = StdRng::seed_from_u64(42);
1007/// let samples = adaptive_mcmc_chain(
1008///     &mut rng,
1009///     model_fn,
1010///     5, // samples (very small for test)
1011///     1, // warmup
1012/// );
1013///
1014/// // Extract mu estimates
1015/// let mu_values: Vec<f64> = samples.iter()
1016///     .filter_map(|(result, _)| Some(*result))
1017///     .collect();
1018/// assert!(!mu_values.is_empty());
1019/// ```
1020pub fn adaptive_mcmc_chain<A: Clone, R: Rng>(
1021    rng: &mut R,
1022    model_fn: impl Fn() -> Model<A>,
1023    n_samples: usize,
1024    n_warmup: usize,
1025) -> Vec<(A, Trace)> {
1026    adaptive_mcmc_chain_thinned(rng, model_fn, n_samples, n_warmup, 1)
1027}
1028
1029/// Like [`adaptive_mcmc_chain`], but retaining only every `thin`-th draw.
1030///
1031/// # Why this exists
1032///
1033/// [`adaptive_mcmc_chain`] materializes **every** iteration: it pushes an
1034/// `(A, Trace)` per step into the `Vec` it returns by value. A caller that only
1035/// wants a thinned subsequence — which is the common case, because
1036/// autocorrelated single-site draws are usually thinned before use — has no way
1037/// to say so, and pays peak memory for the whole chain before discarding most
1038/// of it one line later.
1039///
1040/// The cost is not theoretical. A structure-varying model with ~140 sites over
1041/// a 10 000-step chain holds ~10 000 `Trace` clones of ~140 `BTreeMap` entries
1042/// live simultaneously, to keep 500 of them. On a 32-bit wasm heap that is a
1043/// plausible out-of-memory rather than mere waste, and it scales with
1044/// `n_samples` — so the caller's only lever is to shorten the chain, i.e. to
1045/// pay in statistics for a memory problem.
1046///
1047/// # What is guaranteed
1048///
1049/// **The surviving draws are bit-identical to thinning the full chain.** The
1050/// loop still runs `n_samples` iterations, every transition is still attempted,
1051/// and the RNG is consumed in exactly the same order and quantity — `thin` gates
1052/// the `push` and nothing else. So for any `thin`:
1053///
1054/// ```text
1055/// adaptive_mcmc_chain_thinned(seeded_rng(s), f, n, w, thin)
1056///     == adaptive_mcmc_chain(seeded_rng(s), f, n, w)
1057///            .into_iter().step_by(thin).collect()
1058/// ```
1059///
1060/// Retained indices are `0, thin, 2·thin, …`, matching
1061/// [`Iterator::step_by`]. `thin = 0` is treated as `1`.
1062///
1063/// This is a memory optimization with no statistical content: thinning a chain
1064/// discards information and is *not* a way to improve mixing. Use it when the
1065/// draws were going to be thinned anyway.
1066pub fn adaptive_mcmc_chain_thinned<A: Clone, R: Rng>(
1067    rng: &mut R,
1068    model_fn: impl Fn() -> Model<A>,
1069    n_samples: usize,
1070    n_warmup: usize,
1071    thin: usize,
1072) -> Vec<(A, Trace)> {
1073    let overrides: HashMap<Address, SiteProposal> = HashMap::new();
1074    adaptive_mcmc_chain_with_overrides_thinned(rng, model_fn, n_samples, n_warmup, &overrides, thin)
1075}
1076
1077/// Like [`adaptive_mcmc_chain`], but with explicit per-address `f64` proposal
1078/// overrides (FG-42).
1079///
1080/// Any address present in `overrides` uses the specified [`SiteProposal`] instead
1081/// of the automatically-detected one. This is the escape hatch for cases the
1082/// support-based auto-detection cannot infer (e.g. a `[a,b]`-bounded parameter
1083/// that should use a reflected walk).
1084pub fn adaptive_mcmc_chain_with_overrides<A: Clone, R: Rng>(
1085    rng: &mut R,
1086    model_fn: impl Fn() -> Model<A>,
1087    n_samples: usize,
1088    n_warmup: usize,
1089    overrides: &HashMap<Address, SiteProposal>,
1090) -> Vec<(A, Trace)> {
1091    adaptive_mcmc_chain_with_overrides_thinned(rng, model_fn, n_samples, n_warmup, overrides, 1)
1092}
1093
1094/// [`adaptive_mcmc_chain_with_overrides`] with retention thinning — see
1095/// [`adaptive_mcmc_chain_thinned`] for what `thin` does and does not change.
1096pub fn adaptive_mcmc_chain_with_overrides_thinned<A: Clone, R: Rng>(
1097    rng: &mut R,
1098    model_fn: impl Fn() -> Model<A>,
1099    n_samples: usize,
1100    n_warmup: usize,
1101    overrides: &HashMap<Address, SiteProposal>,
1102    thin: usize,
1103) -> Vec<(A, Trace)> {
1104    // A `thin` of 0 would divide by zero below; it can only mean "keep
1105    // everything", which is what 1 does.
1106    let thin = thin.max(1);
1107    let mut samples = Vec::with_capacity(n_samples.div_ceil(thin));
1108    let mut adaptation = DiminishingAdaptation::new(0.44, 0.7);
1109    let mut kind_cache: HashMap<Address, SiteProposal> = HashMap::new();
1110
1111    // Initialize with a prior sample (fresh, correct accumulators).
1112    let (mut current_a, mut current_trace) = run(
1113        PriorHandler {
1114            rng,
1115            trace: Trace::default(),
1116        },
1117        model_fn(),
1118    );
1119    let mut current_lw = current_trace.total_log_weight();
1120
1121    // FG-11: cache the ordered site list; rebuild only when the address set
1122    // changes. Single-site MH keeps the model structure fixed, so for the common
1123    // case this is built once and reused for the whole chain. For structure-
1124    // varying models the list is refreshed after any accepted move that changed
1125    // the address set — including swaps that keep the site COUNT constant
1126    // (FG-20/FG-21).
1127    let mut sites: Vec<Address> = current_trace.choices.keys().cloned().collect();
1128
1129    // Warmup phase: adapt proposal scales.
1130    for _ in 0..n_warmup {
1131        if let Some((a, t, lw, structure_changed)) = single_site_mh_step(
1132            rng,
1133            &model_fn,
1134            &current_trace,
1135            current_lw,
1136            &sites,
1137            &mut adaptation,
1138            overrides,
1139            &mut kind_cache,
1140            true, // adapt during warmup
1141        ) {
1142            current_a = a;
1143            current_trace = t;
1144            current_lw = lw;
1145            if structure_changed {
1146                sites = current_trace.choices.keys().cloned().collect();
1147            }
1148        }
1149    }
1150
1151    // Sampling phase: FG-57 freeze adaptation so the recorded draws come from a
1152    // single fixed transition kernel.
1153    for i in 0..n_samples {
1154        if let Some((a, t, lw, structure_changed)) = single_site_mh_step(
1155            rng,
1156            &model_fn,
1157            &current_trace,
1158            current_lw,
1159            &sites,
1160            &mut adaptation,
1161            overrides,
1162            &mut kind_cache,
1163            false, // frozen scales during sampling
1164        ) {
1165            current_a = a;
1166            current_trace = t;
1167            current_lw = lw;
1168            if structure_changed {
1169                sites = current_trace.choices.keys().cloned().collect();
1170            }
1171        }
1172        // `thin` gates retention and nothing else — the transition above ran
1173        // regardless, so the chain's arithmetic and its RNG consumption are
1174        // identical at every `thin`. That is what makes the retained draws
1175        // bit-identical to `step_by(thin)` over the unthinned chain, and it is
1176        // why this must stay *below* the step rather than wrapping it.
1177        if i % thin == 0 {
1178            samples.push((current_a.clone(), current_trace.clone()));
1179        }
1180    }
1181
1182    samples
1183}
1184
1185/// Backward-compatible thin wrapper over [`adaptive_single_site_mh`].
1186pub fn single_site_random_walk_mh<A, R: Rng>(
1187    rng: &mut R,
1188    _proposal_sigma: f64,
1189    model_fn: impl Fn() -> Model<A>,
1190    current: &Trace,
1191) -> (A, Trace) {
1192    let mut adaptation = DiminishingAdaptation::new(0.44, 0.7);
1193    adaptive_single_site_mh(rng, model_fn, current, &mut adaptation)
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use crate::addr;
1200    use crate::core::distribution::*;
1201    use crate::core::model::{observe, sample, ModelExt};
1202    use crate::runtime::handler::run;
1203    use rand::rngs::StdRng;
1204    use rand::SeedableRng;
1205
1206    /// **Thinning must not change the chain — only what is kept from it.**
1207    ///
1208    /// This is the whole contract of `adaptive_mcmc_chain_thinned`, and it is
1209    /// the reason the parameter can be added without any statistical review:
1210    /// the transition still runs on every iteration, so the RNG is consumed
1211    /// identically and the retained draws are the *same draws*, not merely
1212    /// draws from the same distribution.
1213    ///
1214    /// Asserted on the values and on the trace weights, at three strides, over
1215    /// a model with more than one site so a structural difference would show.
1216    #[test]
1217    fn thinning_retains_exactly_the_draws_step_by_would() {
1218        let model_fn = || {
1219            sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
1220                sample(addr!("sigma"), LogNormal::new(0.0, 1.0).unwrap()).and_then(move |s| {
1221                    observe(addr!("y"), Normal::new(mu, s).unwrap(), 0.7).map(move |_| (mu, s))
1222                })
1223            })
1224        };
1225
1226        // The unthinned reference, from a fixed seed.
1227        let full = adaptive_mcmc_chain(&mut StdRng::seed_from_u64(0xF117), model_fn, 200, 50);
1228        assert_eq!(full.len(), 200);
1229
1230        for thin in [1usize, 7, 20] {
1231            let thinned = adaptive_mcmc_chain_thinned(
1232                &mut StdRng::seed_from_u64(0xF117),
1233                model_fn,
1234                200,
1235                50,
1236                thin,
1237            );
1238            let expected: Vec<_> = full.iter().step_by(thin).collect();
1239            assert_eq!(
1240                thinned.len(),
1241                expected.len(),
1242                "thin={thin}: kept {} draws, step_by kept {}",
1243                thinned.len(),
1244                expected.len()
1245            );
1246            for (i, (got, want)) in thinned.iter().zip(&expected).enumerate() {
1247                assert_eq!(
1248                    got.0, want.0,
1249                    "thin={thin}: draw {i} differs in value — the chain itself moved"
1250                );
1251                assert_eq!(
1252                    got.1.total_log_weight(),
1253                    want.1.total_log_weight(),
1254                    "thin={thin}: draw {i} differs in trace weight"
1255                );
1256            }
1257        }
1258    }
1259
1260    /// `thin = 0` cannot mean "keep nothing" — it is normalized to 1 rather
1261    /// than dividing by zero.
1262    #[test]
1263    fn thinning_by_zero_keeps_everything() {
1264        let model_fn = || sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap());
1265        let got = adaptive_mcmc_chain_thinned(&mut StdRng::seed_from_u64(7), model_fn, 32, 8, 0);
1266        assert_eq!(got.len(), 32);
1267    }
1268
1269    /// A stride longer than the chain keeps exactly the first draw, matching
1270    /// `step_by`'s behaviour rather than returning nothing.
1271    #[test]
1272    fn thinning_longer_than_the_chain_keeps_the_first_draw() {
1273        let model_fn = || sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap());
1274        let got = adaptive_mcmc_chain_thinned(&mut StdRng::seed_from_u64(7), model_fn, 16, 4, 1000);
1275        assert_eq!(got.len(), 1);
1276    }
1277
1278    /// EA-as-PPL F2: block regeneration over the single latent site of a
1279    /// Beta-Bernoulli model is an independence sampler from the prior and must
1280    /// reproduce the closed-form Beta posterior.
1281    #[test]
1282    #[allow(clippy::needless_borrows_for_generic_args)] // &model_fn is reused across loop iterations
1283    fn test_block_regen_beta_bernoulli() {
1284        use crate::inference::validation::{
1285            test_conjugate_beta_bernoulli_model, ConjugateBetaBernoulliConfig,
1286        };
1287        use crate::runtime::interpreters::PriorHandler;
1288
1289        let observations = vec![
1290            true, true, false, true, false, true, true, false, true, true,
1291        ];
1292        let obs_for_model = observations.clone();
1293        let model_fn = move || {
1294            let obs = obs_for_model.clone();
1295            sample(addr!("theta"), Beta::new(2.0, 2.0).unwrap()).and_then(move |theta| {
1296                let mut m = crate::core::model::pure(());
1297                for (i, &o) in obs.iter().enumerate() {
1298                    m = m.and_then(move |_| {
1299                        observe(
1300                            addr!("obs", i),
1301                            Bernoulli::new(theta.clamp(1e-9, 1.0 - 1e-9)).unwrap(),
1302                            o,
1303                        )
1304                    });
1305                }
1306                m.map(move |_| theta)
1307            })
1308        };
1309
1310        let mcmc_fn = |rng: &mut StdRng, n_samples: usize, n_warmup: usize| {
1311            let (_, mut current) = run(
1312                PriorHandler {
1313                    rng,
1314                    trace: Trace::default(),
1315                },
1316                model_fn(),
1317            );
1318            let block = [addr!("theta")];
1319            let mut samples = Vec::with_capacity(n_samples);
1320            for it in 0..(n_samples + n_warmup) {
1321                let (v, t) = block_regeneration_mh(rng, &model_fn, &current, &block, 1.0);
1322                current = t;
1323                if it >= n_warmup {
1324                    samples.push((v, current.clone()));
1325                }
1326            }
1327            samples
1328        };
1329
1330        let mut rng = StdRng::seed_from_u64(34);
1331        let result = test_conjugate_beta_bernoulli_model(
1332            &mut rng,
1333            mcmc_fn,
1334            ConjugateBetaBernoulliConfig {
1335                prior_alpha: 2.0,
1336                prior_beta: 2.0,
1337                observations,
1338                n_samples: 8000,
1339                n_warmup: 500,
1340            },
1341        );
1342        assert!(
1343            result.is_valid(),
1344            "block-regeneration chain failed conjugate Beta-Bernoulli validation"
1345        );
1346    }
1347
1348    /// EA-as-PPL F2: on a fixed-structure product-Normal model, a single block
1349    /// move over ALL sites targets the same (analytic) posterior as the
1350    /// single-site kernel — the prior-cancellation collapse `log α = β·Δloglik`.
1351    #[test]
1352    #[allow(clippy::needless_borrows_for_generic_args)] // &model_fn is reused across loop iterations
1353    fn test_block_vs_sequential_single_site() {
1354        use crate::runtime::interpreters::PriorHandler;
1355
1356        // Two independent Normal(0,1) sites, each observed with sd 1:
1357        // posterior per site is Normal(y/2, 1/2).
1358        let (y0, y1) = (1.0, -0.5);
1359        let model_fn = move || {
1360            sample(addr!("x", 0), Normal::new(0.0, 1.0).unwrap()).and_then(move |x0| {
1361                sample(addr!("x", 1), Normal::new(0.0, 1.0).unwrap()).and_then(move |x1| {
1362                    observe(addr!("y", 0), Normal::new(x0, 1.0).unwrap(), y0).and_then(move |_| {
1363                        observe(addr!("y", 1), Normal::new(x1, 1.0).unwrap(), y1)
1364                            .map(move |_| (x0, x1))
1365                    })
1366                })
1367            })
1368        };
1369
1370        let mut rng = StdRng::seed_from_u64(44);
1371        let (_, mut current) = run(
1372            PriorHandler {
1373                rng: &mut rng,
1374                trace: Trace::default(),
1375            },
1376            model_fn(),
1377        );
1378        let block = [addr!("x", 0), addr!("x", 1)];
1379        let mut xs0 = Vec::new();
1380        let mut xs1 = Vec::new();
1381        for it in 0..6000 {
1382            let (_, t) = block_regeneration_mh(&mut rng, &model_fn, &current, &block, 1.0);
1383            current = t;
1384            if it >= 500 {
1385                xs0.push(current.get_f64(&addr!("x", 0)).unwrap());
1386                xs1.push(current.get_f64(&addr!("x", 1)).unwrap());
1387            }
1388        }
1389        for (xs, y) in [(&xs0, y0), (&xs1, y1)] {
1390            let mean: f64 = xs.iter().sum::<f64>() / xs.len() as f64;
1391            let var: f64 = xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / xs.len() as f64;
1392            assert!(
1393                (mean - y / 2.0).abs() < 0.08,
1394                "posterior mean {} vs analytic {}",
1395                mean,
1396                y / 2.0
1397            );
1398            assert!((var - 0.5).abs() < 0.08, "posterior var {} vs 0.5", var);
1399        }
1400    }
1401
1402    /// EA-as-PPL F2: trans-dimensional block regeneration. A Bernoulli switch
1403    /// gates an extra Normal site; the block = {switch, extra} move opens and
1404    /// closes the branch, and the fresh/vanished bookkeeping must reproduce the
1405    /// analytic posterior over the switch.
1406    #[test]
1407    #[allow(clippy::needless_borrows_for_generic_args)] // &model_fn is reused across loop iterations
1408    fn test_block_regen_transdimensional() {
1409        use crate::runtime::interpreters::PriorHandler;
1410
1411        let y = 1.5;
1412        let p_switch = 0.3;
1413        let model_fn = move || {
1414            sample(addr!("b"), Bernoulli::new(p_switch).unwrap()).and_then(move |b| {
1415                if b {
1416                    sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()).and_then(move |x| {
1417                        observe(addr!("y"), Normal::new(x, 1.0).unwrap(), y).map(move |_| b)
1418                    })
1419                } else {
1420                    observe(addr!("y"), Normal::new(0.0, 2.0).unwrap(), y).map(move |_| b)
1421                }
1422            })
1423        };
1424
1425        // Analytic: p(y|b=1) = N(y; 0, sqrt(2)) (x marginalized), p(y|b=0) = N(y; 0, 2).
1426        let lik1 = normal_logpdf(y, 0.0, std::f64::consts::SQRT_2).exp();
1427        let lik0 = normal_logpdf(y, 0.0, 2.0).exp();
1428        let post_b1 = p_switch * lik1 / (p_switch * lik1 + (1.0 - p_switch) * lik0);
1429
1430        let mut rng = StdRng::seed_from_u64(55);
1431        let (_, mut current) = run(
1432            PriorHandler {
1433                rng: &mut rng,
1434                trace: Trace::default(),
1435            },
1436            model_fn(),
1437        );
1438        let block = [addr!("b"), addr!("x")];
1439        let mut b_sum = 0.0;
1440        let mut n = 0.0;
1441        for it in 0..20000 {
1442            let (_, t) = block_regeneration_mh(&mut rng, &model_fn, &current, &block, 1.0);
1443            current = t;
1444            if it >= 1000 {
1445                b_sum += if current.get_bool(&addr!("b")).unwrap() {
1446                    1.0
1447                } else {
1448                    0.0
1449                };
1450                n += 1.0;
1451            }
1452        }
1453        let b_mean = b_sum / n;
1454        assert!(
1455            (b_mean - post_b1).abs() < 0.03,
1456            "P(b=1) estimate {} vs analytic {}",
1457            b_mean,
1458            post_b1
1459        );
1460    }
1461
1462    /// EA-as-PPL F2 (FG-48 style): every state the block-regeneration chain
1463    /// returns carries accumulators equal to a fresh from-scratch re-score.
1464    #[test]
1465    #[allow(clippy::needless_borrows_for_generic_args)] // &model_fn is reused across loop iterations
1466    fn test_block_regen_fresh_rescore_equality() {
1467        use crate::runtime::interpreters::PriorHandler;
1468
1469        let model_fn = || {
1470            sample(addr!("a"), Normal::new(0.0, 1.0).unwrap()).and_then(|a| {
1471                sample(addr!("b"), Normal::new(a, 1.0).unwrap()).and_then(move |b| {
1472                    observe(addr!("y"), Normal::new(a + b, 0.5).unwrap(), 0.7).map(move |_| (a, b))
1473                })
1474            })
1475        };
1476        let mut rng = StdRng::seed_from_u64(66);
1477        let (_, mut current) = run(
1478            PriorHandler {
1479                rng: &mut rng,
1480                trace: Trace::default(),
1481            },
1482            model_fn(),
1483        );
1484        let block = [addr!("a")];
1485        for _ in 0..50 {
1486            let (_, t) = block_regeneration_mh(&mut rng, &model_fn, &current, &block, 1.0);
1487            let (_, rescored) = run(
1488                ScoreGivenTrace {
1489                    base: t.clone(),
1490                    trace: Trace::default(),
1491                },
1492                model_fn(),
1493            );
1494            assert!(
1495                (t.total_log_weight() - rescored.total_log_weight()).abs() < 1e-12,
1496                "returned trace's accumulators diverge from a fresh re-score"
1497            );
1498            current = t;
1499        }
1500    }
1501
1502    #[test]
1503    fn gaussian_walk_proposal_produces_variation() {
1504        let mut rng = StdRng::seed_from_u64(11);
1505        let strat = GaussianWalkProposal;
1506        let x1 = strat.propose(0.0, 1.0, &mut rng);
1507        assert!(x1.is_finite());
1508    }
1509
1510    #[test]
1511    fn log_space_proposal_maintains_positivity() {
1512        let mut rng = StdRng::seed_from_u64(42);
1513        let strat = LogSpaceWalkProposal;
1514        for &current in &[0.1, 1.0, 10.0, 100.0] {
1515            for _ in 0..20 {
1516                let proposed = strat.propose(current, 0.5, &mut rng);
1517                assert!(
1518                    proposed > 0.0,
1519                    "LogSpaceWalk proposed non-positive: {current} -> {proposed}"
1520                );
1521                assert!(
1522                    proposed.is_finite(),
1523                    "LogSpaceWalk proposed non-finite: {proposed}"
1524                );
1525            }
1526        }
1527    }
1528
1529    // FG-02: the log-space walk's Jacobian/Hastings correction must equal
1530    // +(ln x' − ln x). log_proposal_prob returns N(ln·) − ln·, so the net
1531    // reverse−forward correction is exactly that. Verify numerically.
1532    #[test]
1533    fn log_space_jacobian_is_correct() {
1534        let s = LogSpaceWalkProposal;
1535        let (x, xp, scale) = (2.0_f64, 3.5_f64, 0.7_f64);
1536        let fwd = s.log_proposal_prob(x, xp, scale);
1537        let rev = s.log_proposal_prob(xp, x, scale);
1538        let net = rev - fwd;
1539        let expected = xp.ln() - x.ln();
1540        assert!(
1541            (net - expected).abs() < 1e-12,
1542            "net correction {net} != {expected}"
1543        );
1544    }
1545
1546    #[test]
1547    fn reflection_proposal_respects_bounds() {
1548        let mut rng = StdRng::seed_from_u64(43);
1549        let strat = ReflectionWalkProposal {
1550            lower_bound: 0.0,
1551            upper_bound: 1.0,
1552        };
1553        for &current in &[0.1, 0.5, 0.9] {
1554            for _ in 0..20 {
1555                let proposed = strat.propose(current, 0.3, &mut rng);
1556                assert!(
1557                    (0.0..=1.0).contains(&proposed),
1558                    "bounds violated: {current} -> {proposed}"
1559                );
1560            }
1561        }
1562    }
1563
1564    #[test]
1565    fn discrete_and_flip_proposals_preserve_types() {
1566        let mut rng = StdRng::seed_from_u64(12);
1567        let u = DiscreteWalkProposal.propose(5u64, 1.0, &mut rng);
1568        let _ = u;
1569        let b = FlipProposal.propose(true, 1.0, &mut rng);
1570        assert!(!b); // deterministic flip
1571    }
1572
1573    // FG-41: the reflected discrete walk must be a symmetric kernel, including at
1574    // the boundary state 0 (where naive |x+δ| is asymmetric by a factor of 2).
1575    // Estimate q(a→b) and q(b→a) by Monte Carlo and check equality for pairs that
1576    // straddle the boundary.
1577    #[test]
1578    fn discrete_walk_is_symmetric_at_boundary() {
1579        let mut rng = StdRng::seed_from_u64(2718);
1580        let s = DiscreteWalkProposal;
1581        let scale = 1.5;
1582        let iters = 400_000;
1583        // Estimate transition probabilities for the pairs (0,1) and (1,0) etc.
1584        let estimate = |from: u64, to: u64, rng: &mut StdRng| -> f64 {
1585            let mut hits = 0u64;
1586            for _ in 0..iters {
1587                if s.propose(from, scale, rng) == to {
1588                    hits += 1;
1589                }
1590            }
1591            hits as f64 / iters as f64
1592        };
1593        for &(a, b) in &[(0u64, 1u64), (0, 2), (1, 3), (2, 5)] {
1594            let q_ab = estimate(a, b, &mut rng);
1595            let q_ba = estimate(b, a, &mut rng);
1596            // Symmetric: q(a→b) == q(b→a). Tolerance covers MC noise on ~4e5 draws.
1597            let diff = (q_ab - q_ba).abs();
1598            assert!(
1599                diff < 0.004,
1600                "asymmetry at ({a},{b}): q_ab={q_ab:.4}, q_ba={q_ba:.4}, diff={diff:.4}"
1601            );
1602        }
1603    }
1604
1605    #[test]
1606    fn adaptive_chain_runs_and_returns_samples() {
1607        let model_fn = || {
1608            sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
1609                observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.5).map(move |_| mu)
1610            })
1611        };
1612        let mut rng = StdRng::seed_from_u64(13);
1613        let samples = adaptive_mcmc_chain(&mut rng, model_fn, 5, 2);
1614        assert_eq!(samples.len(), 5);
1615        for (_val, t) in &samples {
1616            assert!(t.get_f64(&addr!("mu")).is_some());
1617        }
1618    }
1619
1620    // FG-40: accepted samples carry freshly-scored accumulators — the returned
1621    // trace's total_log_weight() must equal a fresh full rescore.
1622    #[test]
1623    fn returned_trace_weight_matches_fresh_rescore() {
1624        let model_fn = || {
1625            sample(addr!("mu"), Normal::new(0.0, 2.0).unwrap()).and_then(|mu| {
1626                observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 1.3).map(move |_| mu)
1627            })
1628        };
1629        let mut rng = StdRng::seed_from_u64(77);
1630        let samples = adaptive_mcmc_chain(&mut rng, model_fn, 20, 20);
1631        for (_v, t) in &samples {
1632            let (_a, fresh) = run(
1633                ScoreGivenTrace {
1634                    base: t.clone(),
1635                    trace: Trace::default(),
1636                },
1637                model_fn(),
1638            );
1639            assert!(
1640                (t.total_log_weight() - fresh.total_log_weight()).abs() < 1e-9,
1641                "stale accumulators: {} vs {}",
1642                t.total_log_weight(),
1643                fresh.total_log_weight()
1644            );
1645        }
1646    }
1647
1648    // FG-11 / FG-12: each transition re-executes the model exactly once. The
1649    // chain builds the model once for the initial prior draw and once per step;
1650    // on rejection there is no extra run. Count model_fn invocations.
1651    #[test]
1652    fn one_model_run_per_transition() {
1653        use std::cell::Cell;
1654        let count = Cell::new(0usize);
1655        let model_fn = || {
1656            count.set(count.get() + 1);
1657            sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
1658                observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.5).map(move |_| mu)
1659            })
1660        };
1661        let mut rng = StdRng::seed_from_u64(5);
1662        let n_warmup = 30;
1663        let n_samples = 40;
1664        let _ = adaptive_mcmc_chain(&mut rng, model_fn, n_samples, n_warmup);
1665        // 1 initial prior build + one build per warmup + sampling step.
1666        assert_eq!(count.get(), 1 + n_warmup + n_samples);
1667    }
1668
1669    // FG-57: scales must be frozen during the sampling phase. Drive the internal
1670    // step with adapt=false and confirm the scale map does not change, while
1671    // adapt=true does change it.
1672    #[test]
1673    fn adaptation_freezes_after_warmup() {
1674        let model_fn = || {
1675            sample(addr!("mu"), Normal::new(0.0, 1.0).unwrap()).and_then(|mu| {
1676                observe(addr!("y"), Normal::new(mu, 1.0).unwrap(), 0.5).map(move |_| mu)
1677            })
1678        };
1679        let mut rng = StdRng::seed_from_u64(99);
1680        let mut adaptation = DiminishingAdaptation::new(0.44, 0.7);
1681        let overrides: HashMap<Address, SiteProposal> = HashMap::new();
1682        let mut kind_cache: HashMap<Address, SiteProposal> = HashMap::new();
1683
1684        let (_a, mut current) = run(
1685            PriorHandler {
1686                rng: &mut rng,
1687                trace: Trace::default(),
1688            },
1689            model_fn(),
1690        );
1691        let mut current_lw = current.total_log_weight();
1692        let sites: Vec<Address> = current.choices.keys().cloned().collect();
1693
1694        // Warm up with adaptation on.
1695        for _ in 0..100 {
1696            if let Some((_a, t, lw, _sc)) = single_site_mh_step(
1697                &mut rng,
1698                &model_fn,
1699                &current,
1700                current_lw,
1701                &sites,
1702                &mut adaptation,
1703                &overrides,
1704                &mut kind_cache,
1705                true,
1706            ) {
1707                current = t;
1708                current_lw = lw;
1709            }
1710        }
1711        let scales_before = adaptation.scales.clone();
1712
1713        // Sampling with adaptation frozen: scales must be untouched.
1714        for _ in 0..200 {
1715            if let Some((_a, t, lw, _sc)) = single_site_mh_step(
1716                &mut rng,
1717                &model_fn,
1718                &current,
1719                current_lw,
1720                &sites,
1721                &mut adaptation,
1722                &overrides,
1723                &mut kind_cache,
1724                false,
1725            ) {
1726                current = t;
1727                current_lw = lw;
1728            }
1729        }
1730        assert_eq!(
1731            scales_before, adaptation.scales,
1732            "scales changed while adaptation was frozen"
1733        );
1734
1735        // Sanity: with adaptation on, the scale does move.
1736        let before = adaptation.get_scale(&sites[0]);
1737        for _ in 0..100 {
1738            let _ = single_site_mh_step(
1739                &mut rng,
1740                &model_fn,
1741                &current,
1742                current_lw,
1743                &sites,
1744                &mut adaptation,
1745                &overrides,
1746                &mut kind_cache,
1747                true,
1748            );
1749        }
1750        let after = adaptation.get_scale(&sites[0]);
1751        assert!(
1752            (before - after).abs() > 0.0,
1753            "adaptation did nothing while enabled"
1754        );
1755    }
1756}