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(¤t))
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(¤t);
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 ¤t.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 ¤t_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 ¤t_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, ¤t, &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, ¤t, &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, ¤t, &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, ¤t, &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 ¤t 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 ¤t 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 ¤t,
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 ¤t,
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 ¤t,
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}