Skip to main content

fugue_evo/inference/
grammar.rs

1//! Expression trees as probabilistic grammars: genetic programming as exact
2//! Bayesian inference
3//!
4//! [`ArithmeticGrammarPrior`] is a probabilistic context-free grammar over
5//! [`TreeGenome`] expression trees, written as a fugue program. Every node at
6//! tree path `p` (root key `"node"`, children `"node/0"`, `"node/0/1"`, …)
7//! emits real probabilistic choices at path-keyed addresses:
8//!
9//! | Site | Address | Distribution |
10//! |---|---|---|
11//! | leaf-vs-function | `<path>#leaf` | `Bernoulli(terminal_prob)` (forced at max depth) |
12//! | terminal kind | `<path>#tkind` | `Categorical([p_var, p_const])` |
13//! | variable index | `<path>#var` | `Categorical(uniform over n_vars)` |
14//! | constant value | `<path>#const` | `Normal(0, const_std)` |
15//! | function choice | `<path>#func` | `Categorical(uniform over F::functions())` |
16//!
17//! Because the structure of an execution is *encoded in its own choices*, the
18//! generic trace machinery becomes genetic programming for free:
19//!
20//! - **Subtree regeneration mutation** is fugue's ordinary single-site MH: a
21//!   flip of one `#leaf` bit (or a change of `#func` arity) births or kills the
22//!   subtree below it, with the fresh sites drawn from this grammar and the
23//!   reversible-jump corrections applied by `propose_and_score` — no bespoke
24//!   acceptance math anywhere in this crate.
25//! - **Subtree crossover** is a [`CrossoverKernel`](fugue::CrossoverKernel)
26//!   whose mask is the union of both parents' addresses under one shared node
27//!   path ([`subtree_crossover_mask`]): swapping that block grafts each
28//!   parent's subtree into the other, and the re-score replays each child
29//!   consistently because the grafted choices themselves describe the new
30//!   structure.
31//!
32//! Parsimony needs no ad-hoc penalty: deeper trees pay more grammar prior mass
33//! by construction.
34//!
35//! Note: tree genomes are decoded from particles by replay (the model returns
36//! the built [`TreeGenome`]); the flat [`TraceGenome`](crate::genome::trace_genome::TraceGenome) encoding of
37//! `TreeGenome` is unrelated to this grammar's address scheme; the prior
38//! therefore overrides [`GenomePrior::trace_of`] with its own encoding, which
39//! is what `EvolutionModel::score` / `to_weighted_trace` /
40//! `EvolutionChain::init_from` replay.
41
42use fugue::{addr, sample, Address, Bernoulli, Categorical, Model, ModelExt, Normal, Trace};
43
44/// The mask-closure type consumed by [`fugue::CrossoverKernel`].
45pub type CrossoverMaskFn =
46    Box<dyn Fn(&Trace, &Trace, &mut dyn rand::RngCore) -> Vec<Address> + Send>;
47
48use super::prior::GenomePrior;
49use crate::genome::tree::{ArithmeticFunction, ArithmeticTerminal, Function, TreeGenome, TreeNode};
50
51/// A probabilistic context-free grammar prior over arithmetic expression
52/// trees.
53#[derive(Clone, Debug)]
54pub struct ArithmeticGrammarPrior {
55    /// Probability that a node (below max depth) is a terminal.
56    pub terminal_prob: f64,
57    /// Maximum tree depth; nodes at this depth are forced terminal.
58    pub max_depth: usize,
59    /// Number of input variables `x0..x{n_vars-1}`.
60    pub n_vars: usize,
61    /// Probability that a terminal is a variable (vs a constant).
62    pub p_var: f64,
63    /// Standard deviation of the Gaussian prior over constants.
64    pub const_std: f64,
65    /// Restrict the function set to the first `n_functions` entries of
66    /// [`ArithmeticFunction::functions`] (e.g. 4 = {Add, Sub, Mul, Div}).
67    pub n_functions: usize,
68}
69
70impl Default for ArithmeticGrammarPrior {
71    fn default() -> Self {
72        Self {
73            terminal_prob: 0.4,
74            max_depth: 6,
75            n_vars: 1,
76            p_var: 0.6,
77            const_std: 2.0,
78            n_functions: 4, // Add, Sub, Mul, Div
79        }
80    }
81}
82
83fn child_key(key: &str, i: usize) -> String {
84    format!("{key}/{i}")
85}
86
87impl ArithmeticGrammarPrior {
88    fn node_model(
89        &self,
90        key: String,
91        depth: usize,
92    ) -> Model<TreeNode<ArithmeticTerminal, ArithmeticFunction>> {
93        let cfg = self.clone();
94        let p_leaf = if depth >= cfg.max_depth {
95            1.0
96        } else {
97            cfg.terminal_prob
98        };
99        sample(
100            addr!(key.clone(), "leaf"),
101            Bernoulli::new(p_leaf).expect("valid leaf probability"),
102        )
103        .bind(move |is_leaf| {
104            if is_leaf {
105                cfg.terminal_model(&key)
106            } else {
107                let n_funcs = cfg.n_functions.min(ArithmeticFunction::functions().len());
108                let probs = vec![1.0 / n_funcs as f64; n_funcs];
109                sample(
110                    addr!(key.clone(), "func"),
111                    Categorical::new(probs).expect("valid function categorical"),
112                )
113                .bind(move |fi| {
114                    let func = ArithmeticFunction::functions()[fi].clone();
115                    let arity = func.arity();
116                    let children: Vec<Model<TreeNode<ArithmeticTerminal, ArithmeticFunction>>> = (0
117                        ..arity)
118                        .map(|c| cfg.node_model(child_key(&key, c), depth + 1))
119                        .collect();
120                    fugue::sequence_vec(children)
121                        .map(move |kids| TreeNode::function(func.clone(), kids))
122                })
123            }
124        })
125    }
126
127    fn terminal_model(&self, key: &str) -> Model<TreeNode<ArithmeticTerminal, ArithmeticFunction>> {
128        let (p_var, n_vars, const_std) = (self.p_var, self.n_vars.max(1), self.const_std);
129        let key = key.to_string();
130        sample(
131            addr!(key.clone(), "tkind"),
132            Categorical::new(vec![p_var, 1.0 - p_var]).expect("valid terminal-kind categorical"),
133        )
134        .bind(move |kind| {
135            if kind == 0 {
136                let probs = vec![1.0 / n_vars as f64; n_vars];
137                sample(
138                    addr!(key.clone(), "var"),
139                    Categorical::new(probs).expect("valid variable categorical"),
140                )
141                .map(|i| TreeNode::terminal(ArithmeticTerminal::Variable(i)))
142            } else {
143                sample(
144                    addr!(key.clone(), "const"),
145                    Normal::new(0.0, const_std).expect("valid constant prior"),
146                )
147                .map(|c| TreeNode::terminal(ArithmeticTerminal::Constant(c)))
148            }
149        })
150    }
151}
152
153impl GenomePrior for ArithmeticGrammarPrior {
154    type Genome = TreeGenome<ArithmeticTerminal, ArithmeticFunction>;
155
156    fn model(&self) -> Model<Self::Genome> {
157        let max_depth = self.max_depth;
158        self.node_model("node".to_string(), 0)
159            .map(move |root| TreeGenome::new(root, max_depth))
160    }
161
162    /// Encode a tree under the grammar's own address scheme (the inverse of
163    /// running [`Self::model`]): a deterministic walk emitting the
164    /// `#leaf`/`#tkind`/`#var`/`#const`/`#func` choices at each node path.
165    ///
166    /// This is what makes `EvolutionModel::score`, `to_weighted_trace`, and
167    /// `EvolutionChain::init_from` work for grammar-driven trees: replaying
168    /// the encoding through the grammar program recovers the genuine PCFG
169    /// log-prior. A tree using a function outside the restricted
170    /// `n_functions` set, or deeper than `max_depth`, scores `−∞` under
171    /// replay (out of the prior's support) rather than erroring.
172    ///
173    /// `Erc` terminals are encoded as constants (evaluation-identical; the
174    /// grammar itself only generates `Variable`/`Constant`).
175    fn trace_of(&self, genome: &Self::Genome) -> Trace {
176        fn walk(
177            node: &TreeNode<ArithmeticTerminal, ArithmeticFunction>,
178            key: &str,
179            trace: &mut Trace,
180        ) {
181            use fugue::ChoiceValue;
182            match node {
183                TreeNode::Terminal(term) => {
184                    trace.insert_choice(addr!(key, "leaf"), ChoiceValue::Bool(true), 0.0);
185                    match term {
186                        ArithmeticTerminal::Variable(i) => {
187                            trace.insert_choice(addr!(key, "tkind"), ChoiceValue::Usize(0), 0.0);
188                            trace.insert_choice(addr!(key, "var"), ChoiceValue::Usize(*i), 0.0);
189                        }
190                        ArithmeticTerminal::Constant(c) | ArithmeticTerminal::Erc(c) => {
191                            trace.insert_choice(addr!(key, "tkind"), ChoiceValue::Usize(1), 0.0);
192                            trace.insert_choice(addr!(key, "const"), ChoiceValue::F64(*c), 0.0);
193                        }
194                    }
195                }
196                TreeNode::Function(func, children) => {
197                    trace.insert_choice(addr!(key, "leaf"), ChoiceValue::Bool(false), 0.0);
198                    let fi = ArithmeticFunction::functions()
199                        .iter()
200                        .position(|f| f == func)
201                        .expect("function present in the canonical table");
202                    trace.insert_choice(addr!(key, "func"), ChoiceValue::Usize(fi), 0.0);
203                    for (c, child) in children.iter().enumerate() {
204                        walk(child, &child_key(key, c), trace);
205                    }
206                }
207            }
208        }
209        let mut trace = Trace::default();
210        walk(&genome.root, "node", &mut trace);
211        trace
212    }
213}
214
215/// Gaussian-noise regression of a dataset under a candidate expression tree,
216/// as an **observation program** — per-datum `observe` statements, with the
217/// noise scale either fixed or a **latent site jointly inferred** with the
218/// program. This is the capability the scalar-factor fitness could never
219/// express: hyperparameters of the "fitness" become posterior quantities,
220/// read straight off the particle traces at `addr!("sigma")`.
221#[derive(Clone, Debug)]
222pub struct GaussianRegression {
223    /// Input points (single variable).
224    pub xs: Vec<f64>,
225    /// Observed outputs.
226    pub ys: Vec<f64>,
227    /// Observation-noise model.
228    pub noise: NoiseSpec,
229}
230
231/// How the observation noise enters the regression likelihood.
232#[derive(Clone, Debug)]
233pub enum NoiseSpec {
234    /// Known, fixed noise standard deviation.
235    Fixed(f64),
236    /// Unknown noise: `σ ~ Uniform(low, high)` as a latent site at
237    /// `addr!("sigma")`, jointly inferred with the program.
238    Infer {
239        /// Lower bound of the uniform prior over σ.
240        low: f64,
241        /// Upper bound of the uniform prior over σ.
242        high: f64,
243    },
244}
245
246impl super::likelihood::GenomeLikelihood<TreeGenome<ArithmeticTerminal, ArithmeticFunction>>
247    for GaussianRegression
248{
249    fn model(
250        &self,
251        tree: &TreeGenome<ArithmeticTerminal, ArithmeticFunction>,
252        beta: f64,
253    ) -> Model<()> {
254        use super::likelihood::tempered_observe;
255        // Evaluate the candidate program once per datum, up front. A
256        // non-finite prediction crushes the whole likelihood.
257        let preds: Vec<f64> = self.xs.iter().map(|&x| tree.evaluate(&[x])).collect();
258        if preds.iter().any(|p| !p.is_finite()) {
259            return fugue::factor(f64::NEG_INFINITY);
260        }
261        let ys = self.ys.clone();
262        let observe_all =
263            move |sigma: f64, beta: f64, preds: Vec<f64>, ys: Vec<f64>| -> Model<()> {
264                let mut m = fugue::pure(());
265                for (k, (pred, y)) in preds.into_iter().zip(ys).enumerate() {
266                    m = m.and_then(move |_| {
267                        tempered_observe(
268                            addr!("y", k),
269                            Normal::new(pred, sigma).expect("valid observation noise"),
270                            y,
271                            beta,
272                        )
273                    });
274                }
275                m
276            };
277        match self.noise {
278            NoiseSpec::Fixed(sigma) => observe_all(sigma, beta, preds, ys),
279            NoiseSpec::Infer { low, high } => sample(
280                addr!("sigma"),
281                fugue::Uniform::new(low, high).expect("valid noise prior bounds"),
282            )
283            .bind(move |sigma| observe_all(sigma, beta, preds.clone(), ys.clone())),
284        }
285    }
286}
287
288/// A value-independent, pair-symmetric **subtree crossover** mask for
289/// [`fugue::CrossoverKernel`]: picks one node path present in *both* parents
290/// uniformly at random and returns the union of the two parents' addresses
291/// under that path. Swapping that block grafts each parent's subtree into the
292/// other; the kernel's mandatory re-score replays each child consistently
293/// (the grafted choices encode the new structure) and rejects off-support or
294/// low-density grafts via the product-target Metropolis ratio.
295pub fn subtree_crossover_mask() -> CrossoverMaskFn {
296    Box::new(|a: &Trace, b: &Trace, rng: &mut dyn rand::RngCore| {
297        // Node paths of a trace = the `<path>#leaf` site keys.
298        let paths_of = |t: &Trace| -> Vec<String> {
299            t.choices
300                .keys()
301                .filter_map(|addr| addr.as_str().strip_suffix("#leaf").map(str::to_string))
302                .collect()
303        };
304        let pa = paths_of(a);
305        let pb: std::collections::HashSet<String> = paths_of(b).into_iter().collect();
306        let shared: Vec<String> = pa.into_iter().filter(|p| pb.contains(p)).collect();
307        if shared.is_empty() {
308            return Vec::new();
309        }
310        let path = &shared[rand::Rng::gen_range(rng, 0..shared.len())];
311        let mut block: Vec<Address> = a.extract_prefix(path).choices.keys().cloned().collect();
312        for addr in b.extract_prefix(path).choices.keys() {
313            if !block.contains(addr) {
314                block.push(addr.clone());
315            }
316        }
317        block
318    })
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::fitness::traits::Fitness;
325    use crate::inference::model::EvolutionModel;
326    use crate::inference::smc::{EvoSmcConfig, EvolutionSMC};
327    use fugue::runtime::handler::run;
328    use fugue::runtime::interpreters::PriorHandler;
329    use fugue::{CrossoverKernel, PopulationKernel, ResamplingMethod};
330    use rand::rngs::StdRng;
331    use rand::SeedableRng;
332
333    /// The grammar trace carries genuine probability mass: `log_prior` is the
334    /// real PCFG log-probability of the drawn tree, never 0. (Replaces the
335    /// flat, `logp = 0.0` serialization story for trees.)
336    #[test]
337    fn test_grammar_trace_has_real_log_prior() {
338        let prior = ArithmeticGrammarPrior::default();
339        let mut rng = StdRng::seed_from_u64(3);
340        for _ in 0..20 {
341            let (tree, trace) = run(
342                PriorHandler {
343                    rng: &mut rng,
344                    trace: Trace::default(),
345                },
346                prior.model(),
347            );
348            assert!(trace.log_prior.is_finite());
349            assert!(
350                trace.log_prior < 0.0,
351                "a non-trivial tree draw must pay prior mass, got {}",
352                trace.log_prior
353            );
354            assert!(tree.depth() <= prior.max_depth + 1);
355            // Every node path has its structural site.
356            assert!(trace.choices.keys().any(|a| a.as_str() == "node#leaf"));
357        }
358    }
359
360    /// Deeper trees pay more prior mass — the parsimony pressure is the
361    /// grammar itself, not an ad-hoc penalty.
362    #[test]
363    fn test_grammar_prior_penalizes_depth() {
364        let prior = ArithmeticGrammarPrior::default();
365        let mut rng = StdRng::seed_from_u64(5);
366        let mut sized: Vec<(usize, f64)> = Vec::new();
367        for _ in 0..300 {
368            let (tree, trace) = run(
369                PriorHandler {
370                    rng: &mut rng,
371                    trace: Trace::default(),
372                },
373                prior.model(),
374            );
375            sized.push((tree.size(), trace.log_prior));
376        }
377        let small: Vec<f64> = sized
378            .iter()
379            .filter(|(s, _)| *s <= 3)
380            .map(|(_, lp)| *lp)
381            .collect();
382        let large: Vec<f64> = sized
383            .iter()
384            .filter(|(s, _)| *s >= 7)
385            .map(|(_, lp)| *lp)
386            .collect();
387        assert!(!small.is_empty() && !large.is_empty());
388        let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
389        assert!(
390            mean(&small) > mean(&large),
391            "small trees {} should out-mass large trees {}",
392            mean(&small),
393            mean(&large)
394        );
395    }
396
397    /// Subtree crossover swaps a complete prefix block between two grammar
398    /// traces and both children re-score to valid trees.
399    #[test]
400    fn test_subtree_crossover_swaps_prefix_range() {
401        let mut rng = StdRng::seed_from_u64(21);
402        let model_fn = prior_model_for_test;
403        fn prior_model_for_test() -> Model<TreeGenome<ArithmeticTerminal, ArithmeticFunction>> {
404            ArithmeticGrammarPrior {
405                terminal_prob: 0.3,
406                max_depth: 4,
407                ..Default::default()
408            }
409            .model()
410        }
411
412        // Build a small particle population from the prior.
413        let mut particles = fugue::smc_prior_particles(&mut rng, 12, model_fn);
414        let snapshot = |ps: &[fugue::Particle]| -> Vec<Vec<(Address, fugue::ChoiceValue)>> {
415            ps.iter()
416                .map(|p| {
417                    p.trace
418                        .choices
419                        .iter()
420                        .map(|(a, c)| (a.clone(), c.value.clone()))
421                        .collect()
422                })
423                .collect()
424        };
425        let before = snapshot(&particles);
426
427        let mut kernel = CrossoverKernel {
428            n_pairs: 40,
429            mask: subtree_crossover_mask(),
430        };
431        PopulationKernel::<TreeGenome<ArithmeticTerminal, ArithmeticFunction>>::sweep(
432            &mut kernel,
433            &mut rng,
434            &mut particles,
435            &model_fn,
436            1.0,
437        );
438
439        // Every particle still decodes to a valid tree via replay, with a
440        // finite prior mass (accepted grafts were re-scored).
441        for p in &particles {
442            let tree = fugue::decode_particle(p, model_fn);
443            assert!(tree.size() >= 1);
444            assert!(p.trace.log_prior.is_finite());
445        }
446        // The swap genuinely happened (EV-N5): grafting two subtrees rooted at
447        // the same path conserves the pair's total prior mass (the PCFG is
448        // depth-indexed, and both grafts land at the same depth), so under
449        // the prior-only target every non-trivial proposal is accepted — at
450        // least one particle's choices must differ from its prior draw.
451        let after = snapshot(&particles);
452        let changed = before.iter().zip(&after).filter(|(b, a)| b != a).count();
453        assert!(
454            changed >= 2,
455            "subtree crossover accepted no swap over 40 pair proposals ({changed} changed)"
456        );
457    }
458
459    /// The grammar encoding is the exact inverse of the generative program:
460    /// a prior-drawn tree's `trace_of` reproduces the generative trace's
461    /// choices, and replay-scoring it recovers the same PCFG log-prior.
462    #[test]
463    fn test_trace_of_inverts_generative_run() {
464        use fugue::runtime::interpreters::ScoreGivenTrace;
465        let prior = ArithmeticGrammarPrior::default();
466        let mut rng = StdRng::seed_from_u64(31);
467        for _ in 0..30 {
468            let (tree, gen_trace) = run(
469                PriorHandler {
470                    rng: &mut rng,
471                    trace: Trace::default(),
472                },
473                prior.model(),
474            );
475            let enc = prior.trace_of(&tree);
476            assert_eq!(enc.choices.len(), gen_trace.choices.len());
477            for (addr, choice) in &gen_trace.choices {
478                assert_eq!(
479                    enc.choices[addr].value, choice.value,
480                    "encoding mismatch at {addr}"
481                );
482            }
483            // Replay-scoring the encoding recovers the PCFG log-prior.
484            let (_t, scored) = run(
485                ScoreGivenTrace {
486                    base: enc,
487                    trace: Trace::default(),
488                },
489                prior.model(),
490            );
491            assert!((scored.log_prior - gen_trace.log_prior).abs() < 1e-9);
492        }
493    }
494
495    /// `EvolutionModel::score` now works for grammar trees: a hand-built
496    /// `(+ x0 1.0)` scores to the hand-computed PCFG log-prior plus β·f.
497    #[test]
498    fn test_score_hand_built_tree_matches_analytic() {
499        use crate::genome::tree::TreeNode;
500        #[derive(Clone, Copy)]
501        struct Zero;
502        impl Fitness for Zero {
503            type Genome = TreeGenome<ArithmeticTerminal, ArithmeticFunction>;
504            type Value = f64;
505            fn evaluate(&self, _t: &Self::Genome) -> f64 {
506                0.0
507            }
508        }
509
510        let prior = ArithmeticGrammarPrior {
511            terminal_prob: 0.4,
512            max_depth: 6,
513            n_vars: 1,
514            p_var: 0.6,
515            const_std: 2.0,
516            n_functions: 4,
517        };
518        // (+ x0 1.0)
519        let tree = TreeGenome::new(
520            TreeNode::function(
521                crate::genome::tree::ArithmeticFunction::Add,
522                vec![
523                    TreeNode::terminal(ArithmeticTerminal::Variable(0)),
524                    TreeNode::terminal(ArithmeticTerminal::Constant(1.0)),
525                ],
526            ),
527            6,
528        );
529        let model = crate::inference::model::EvolutionModel::new(prior.clone(), Zero);
530        let (_g, scored) = model.score(&tree).expect("in-grammar tree");
531
532        // Hand-computed PCFG log-prior:
533        //   root: not-leaf (1-0.4) · func Add (1/4)
534        //   child 0: leaf 0.4 · var-kind 0.6 · var 0 (1/1)
535        //   child 1: leaf 0.4 · const-kind 0.4 · Normal(0,2).log_prob(1.0)
536        let normal = fugue::Normal::new(0.0, 2.0).unwrap();
537        let analytic = (0.6f64).ln()
538            + (0.25f64).ln()
539            + (0.4f64).ln()
540            + (0.6f64).ln()
541            + (1.0f64).ln()
542            + (0.4f64).ln()
543            + (0.4f64).ln()
544            + fugue::Distribution::log_prob(&normal, &1.0);
545        assert!(
546            (scored.log_prior - analytic).abs() < 1e-9,
547            "scored {} vs analytic {}",
548            scored.log_prior,
549            analytic
550        );
551
552        // Warm-starting a chain from the hand-built tree works.
553        let chain = crate::inference::mh::EvolutionChain::new(
554            crate::inference::model::EvolutionModel::new(prior, Zero),
555        );
556        let init = chain.init_from(&tree).expect("in-support tree");
557        assert!(init.total_log_weight().is_finite());
558    }
559
560    /// Fix A capstone: the observation noise is a latent site in the
561    /// likelihood program, jointly inferred with the program. Data are
562    /// `y = x + 1 + ε`, `ε ~ N(0, 0.3²)`; the posterior over `σ` (read off
563    /// the particle traces at `addr!("sigma")`) must land near the truth.
564    #[test]
565    fn test_symreg_infers_noise_jointly() {
566        let sigma_true = 0.3;
567        let mut data_rng = StdRng::seed_from_u64(4242);
568        let noise_dist = rand_distr::Normal::new(0.0, sigma_true).unwrap();
569        let xs: Vec<f64> = (-10..=10).map(|i| i as f64 / 5.0).collect();
570        let ys: Vec<f64> = xs
571            .iter()
572            .map(|x| x + 1.0 + rand_distr::Distribution::sample(&noise_dist, &mut data_rng))
573            .collect();
574
575        let prior = ArithmeticGrammarPrior {
576            terminal_prob: 0.45,
577            max_depth: 3,
578            n_vars: 1,
579            p_var: 0.6,
580            const_std: 2.0,
581            n_functions: 1, // {Add} — x + 1 is easily reachable
582        };
583        let likelihood = GaussianRegression {
584            xs: xs.clone(),
585            ys,
586            noise: NoiseSpec::Infer {
587                low: 0.02,
588                high: 2.0,
589            },
590        };
591        let model = crate::inference::model::EvolutionModel::from_likelihood(prior, likelihood);
592        let mut rng = StdRng::seed_from_u64(77);
593        let mut kernel = CrossoverKernel {
594            n_pairs: 150,
595            mask: subtree_crossover_mask(),
596        };
597        let result = EvolutionSMC::run_with_kernel(
598            &mut rng,
599            &model,
600            EvoSmcConfig {
601                num_particles: 500,
602                ess_threshold: 0.5,
603                resampling: ResamplingMethod::Systematic,
604                rejuvenation_steps: 6,
605                crossover: None,
606            },
607            &mut kernel,
608        );
609
610        // Posterior over σ, straight off the traces.
611        let mut total_w = 0.0;
612        let mut sigma_mean = 0.0;
613        for p in &result.particles {
614            if let Some(s) = p.trace.get_f64(&fugue::addr!("sigma")) {
615                sigma_mean += p.weight * s;
616                total_w += p.weight;
617            }
618        }
619        assert!(total_w > 0.99, "every particle carries the sigma site");
620        sigma_mean /= total_w;
621        assert!(
622            (0.15..=0.55).contains(&sigma_mean),
623            "posterior sigma mean {} should be near the true 0.3",
624            sigma_mean
625        );
626
627        // And the programs still fit: posterior-weighted predictions track y = x+1.
628        let model_fn = model.smc_model();
629        let decoded = fugue::decode_particles(&result.particles, &model_fn);
630        for &x in &[-1.0, 0.0, 1.5] {
631            let pred: f64 = decoded
632                .iter()
633                .map(|(tree, w)| {
634                    let p = tree.evaluate(&[x]);
635                    if p.is_finite() {
636                        w * p
637                    } else {
638                        0.0
639                    }
640                })
641                .sum();
642            assert!(
643                (pred - (x + 1.0)).abs() < 0.35,
644                "posterior predictive at {} was {} vs truth {}",
645                x,
646                pred,
647                x + 1.0
648            );
649        }
650    }
651
652    /// Flagship analytic recovery: symbolic regression of `x² + 1` from
653    /// noiseless data, posed as exact Bayesian inference over the grammar.
654    /// The MAP tree's predictions must match the target on a held-out grid.
655    #[test]
656    fn test_symreg_recovers_known_expression() {
657        #[derive(Clone)]
658        struct SymRegFit {
659            xs: Vec<f64>,
660            ys: Vec<f64>,
661            noise: f64,
662        }
663        impl Fitness for SymRegFit {
664            type Genome = TreeGenome<ArithmeticTerminal, ArithmeticFunction>;
665            type Value = f64;
666            fn evaluate(&self, tree: &Self::Genome) -> f64 {
667                let sse: f64 = self
668                    .xs
669                    .iter()
670                    .zip(&self.ys)
671                    .map(|(&x, &y)| {
672                        let pred = tree.evaluate(&[x]);
673                        if pred.is_finite() {
674                            (pred - y).powi(2)
675                        } else {
676                            1e6
677                        }
678                    })
679                    .sum();
680                -0.5 * sse / (self.noise * self.noise)
681            }
682        }
683
684        let xs: Vec<f64> = (-8..=8).map(|i| i as f64 / 4.0).collect();
685        let ys: Vec<f64> = xs.iter().map(|x| x * x + 1.0).collect();
686        let fitness = SymRegFit {
687            xs: xs.clone(),
688            ys: ys.clone(),
689            noise: 0.25,
690        };
691
692        let prior = ArithmeticGrammarPrior {
693            terminal_prob: 0.35,
694            max_depth: 5,
695            n_vars: 1,
696            p_var: 0.6,
697            const_std: 2.0,
698            n_functions: 3, // Add, Sub, Mul — enough for x²+1
699        };
700        let model = EvolutionModel::new(prior, fitness.clone());
701        let mut rng = StdRng::seed_from_u64(20260728);
702        let mut kernel = CrossoverKernel {
703            n_pairs: 200,
704            mask: subtree_crossover_mask(),
705        };
706        let result = EvolutionSMC::run_with_kernel(
707            &mut rng,
708            &model,
709            EvoSmcConfig {
710                num_particles: 600,
711                ess_threshold: 0.5,
712                resampling: ResamplingMethod::Systematic,
713                rejuvenation_steps: 6,
714                crossover: None, // replaced by the explicit subtree kernel
715            },
716            &mut kernel,
717        );
718        let model_fn = model.smc_model();
719        let (best, best_f) = result.best(&fitness, &model_fn).unwrap();
720        // MAP predictions match x²+1 closely on the grid.
721        let max_err = xs
722            .iter()
723            .map(|&x| (best.evaluate(&[x]) - (x * x + 1.0)).abs())
724            .fold(0.0f64, f64::max);
725        assert!(
726            max_err < 0.35,
727            "MAP tree {} (fitness {best_f:.2}) max error {max_err:.3} too large",
728            best.to_sexpr(),
729        );
730    }
731}