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