Skip to main content

fugue_evo/inference/
likelihood.rs

1//! Likelihoods as programs
2//!
3//! The inference layer's conditioning side. A [`GenomeLikelihood`] is an
4//! *observation program* `p(data | genome)` — not merely a scalar score. It
5//! may contain:
6//!
7//! - `observe` statements over real data (per-datum log-likelihoods land in
8//!   the trace's `log_likelihood` accumulator with genuine structure),
9//! - **latent nuisance parameters** (`sample` sites — e.g. an unknown
10//!   observation noise `σ` — which are then *jointly inferred* with the
11//!   genome; their posteriors are read straight off the particle traces),
12//! - `factor` statements for soft constraints or black-box scores.
13//!
14//! The black-box case — an arbitrary fitness `f` entering as `factor(β·f)` —
15//! is the [`FactorFitness`] adapter. That target is a **generalized-Bayes /
16//! Gibbs posterior** (Bissiri, Holmes & Walker 2016): perfectly legitimate,
17//! but now one mode among many rather than the only one.
18
19use std::collections::HashMap;
20use std::sync::{Arc, Mutex};
21
22use fugue::{factor, observe, pure, Address, Distribution, Model, SampleType};
23
24use crate::fitness::traits::Fitness;
25
26/// An observation program `p(data | genome)`, possibly tempered.
27///
28/// `beta` is the likelihood temperature. Implementations decide how it
29/// enters: [`FactorFitness`] scales its factor by `β`; observation models
30/// can use [`tempered_observe`] per datum (which reduces to a plain `observe`
31/// at `β = 1`). Callers on the SMC path always pass `β = 1` — fugue's
32/// adaptive tempering supplies β there, exactly once.
33pub trait GenomeLikelihood<G>: Clone + Send + Sync + 'static {
34    /// The observation program conditioned on `genome`, at likelihood
35    /// temperature `beta`.
36    fn model(&self, genome: &G, beta: f64) -> Model<()>;
37}
38
39/// A tempered observation: at `β = 1` this is exactly `observe(addr, dist,
40/// value)` (the log-density lands in `log_likelihood`); at other `β` it is
41/// `factor(β · log p(value))` (landing in `log_factors`). Both accumulators
42/// are tempered together by fugue's SMC, so the two forms are interchangeable
43/// under tempering — the `β = 1` form is preferred because it keeps the
44/// likelihood/prior decomposition visible in the trace.
45pub fn tempered_observe<T: SampleType>(
46    addr: Address,
47    dist: impl Distribution<T> + 'static,
48    value: T,
49    beta: f64,
50) -> Model<()> {
51    if beta == 1.0 {
52        observe(addr, dist, value)
53    } else {
54        factor(beta * dist.log_prob(&value))
55    }
56}
57
58/// The black-box adapter: a scalar [`Fitness`] entering as `factor(β·f(g))`.
59///
60/// The resulting target `π_β(x) ∝ p(x)·exp(β·f(x))` is the Gibbs /
61/// generalized-Bayes posterior — the classical "fitness as likelihood"
62/// correspondence, now explicitly one [`GenomeLikelihood`] among many.
63#[derive(Clone, Debug)]
64pub struct FactorFitness<F> {
65    /// The wrapped scalar fitness.
66    pub fitness: F,
67}
68
69impl<F> FactorFitness<F> {
70    /// Wrap a scalar fitness as a factor likelihood.
71    pub fn new(fitness: F) -> Self {
72        Self { fitness }
73    }
74}
75
76impl<G, F> GenomeLikelihood<G> for FactorFitness<F>
77where
78    F: Fitness<Genome = G, Value = f64> + Clone + Send + Sync + 'static,
79    G: 'static,
80{
81    fn model(&self, genome: &G, beta: f64) -> Model<()> {
82        factor(beta * self.fitness.evaluate(genome))
83    }
84}
85
86/// A memoizing wrapper around an expensive [`Fitness`].
87///
88/// The inference layer re-evaluates fitness whenever a trace is replayed
89/// (scoring, decode, rejuvenation); when fitness evaluation dominates — the
90/// usual case in evolutionary computation — memoization removes almost all of
91/// that overhead. Keys are the exact `bincode` serialization of the genome
92/// (no hash collisions); the cache is shared across clones (`Arc`) so the
93/// closures a model constructor spawns all hit the same table.
94///
95/// The cache grows without bound; for long runs over continuous genomes
96/// (where exact repeats are rare outside replay) wrap only genuinely
97/// expensive fitness functions.
98#[derive(Clone)]
99pub struct MemoizedFitness<F> {
100    inner: F,
101    cache: Arc<Mutex<HashMap<Vec<u8>, f64>>>,
102}
103
104impl<F> MemoizedFitness<F> {
105    /// Wrap `fitness` with a shared memo table.
106    pub fn new(fitness: F) -> Self {
107        Self {
108            inner: fitness,
109            cache: Arc::new(Mutex::new(HashMap::new())),
110        }
111    }
112
113    /// Number of distinct genomes evaluated so far.
114    pub fn cache_len(&self) -> usize {
115        self.cache.lock().map(|c| c.len()).unwrap_or(0)
116    }
117}
118
119impl<F> Fitness for MemoizedFitness<F>
120where
121    F: Fitness<Value = f64>,
122    F::Genome: serde::Serialize,
123{
124    type Genome = F::Genome;
125    type Value = f64;
126
127    fn evaluate(&self, genome: &Self::Genome) -> f64 {
128        let key = match bincode::serialize(genome) {
129            Ok(k) => k,
130            Err(_) => return self.inner.evaluate(genome), // unkeyable: pass through
131        };
132        if let Ok(cache) = self.cache.lock() {
133            if let Some(&v) = cache.get(&key) {
134                return v;
135            }
136        }
137        let v = self.inner.evaluate(genome);
138        if let Ok(mut cache) = self.cache.lock() {
139            cache.insert(key, v);
140        }
141        v
142    }
143}
144
145/// Convenience: a `()`-like likelihood that conditions on nothing (the
146/// posterior is the prior). Useful for testing priors through the inference
147/// drivers.
148#[derive(Clone, Copy, Debug, Default)]
149pub struct NoLikelihood;
150
151impl<G: 'static> GenomeLikelihood<G> for NoLikelihood {
152    fn model(&self, _genome: &G, _beta: f64) -> Model<()> {
153        pure(())
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::genome::real_vector::RealVector;
161    use crate::genome::traits::RealValuedGenome;
162    use std::sync::atomic::{AtomicUsize, Ordering};
163
164    #[test]
165    fn test_memoized_fitness_evaluates_once_per_genome() {
166        static CALLS: AtomicUsize = AtomicUsize::new(0);
167
168        #[derive(Clone)]
169        struct Counting;
170        impl Fitness for Counting {
171            type Genome = RealVector;
172            type Value = f64;
173            fn evaluate(&self, g: &RealVector) -> f64 {
174                CALLS.fetch_add(1, Ordering::SeqCst);
175                -g.genes().iter().map(|x| x * x).sum::<f64>()
176            }
177        }
178
179        let memo = MemoizedFitness::new(Counting);
180        let a = RealVector::new(vec![1.0, 2.0]);
181        let b = RealVector::new(vec![3.0, 4.0]);
182        let fa = memo.evaluate(&a);
183        for _ in 0..10 {
184            assert_eq!(memo.evaluate(&a), fa);
185        }
186        memo.evaluate(&b);
187        memo.evaluate(&b);
188        assert_eq!(
189            CALLS.load(Ordering::SeqCst),
190            2,
191            "each genome evaluated once"
192        );
193        assert_eq!(memo.cache_len(), 2);
194
195        // Clones share the cache.
196        let clone = memo.clone();
197        clone.evaluate(&a);
198        assert_eq!(CALLS.load(Ordering::SeqCst), 2);
199    }
200
201    #[test]
202    fn test_tempered_observe_matches_observe_at_beta_one() {
203        use fugue::runtime::handler::run;
204        use fugue::runtime::interpreters::PriorHandler;
205        use fugue::{addr, Normal, Trace};
206        use rand::rngs::StdRng;
207        use rand::SeedableRng;
208
209        let mut rng = StdRng::seed_from_u64(1);
210        let dist = Normal::new(0.0, 1.0).unwrap();
211        let (_, t1) = run(
212            PriorHandler {
213                rng: &mut rng,
214                trace: Trace::default(),
215            },
216            tempered_observe(addr!("y"), dist, 0.7, 1.0),
217        );
218        let (_, t2) = run(
219            PriorHandler {
220                rng: &mut rng,
221                trace: Trace::default(),
222            },
223            tempered_observe(addr!("y"), dist, 0.7, 0.5),
224        );
225        let lp = fugue::Distribution::log_prob(&dist, &0.7);
226        assert!((t1.log_likelihood - lp).abs() < 1e-12);
227        assert_eq!(t1.log_factors, 0.0);
228        assert!((t2.log_factors - 0.5 * lp).abs() < 1e-12);
229        assert_eq!(t2.log_likelihood, 0.0);
230        // Under tempering both contribute identically at any β:
231        // β·(log_likelihood + log_factors) is the same either way.
232    }
233}