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}