1use fugue::{addr, sample, Address, Bernoulli, Categorical, Model, ModelExt, Normal, Trace};
43
44pub 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#[derive(Clone, Debug)]
53pub struct ArithmeticGrammarPrior {
54 pub terminal_prob: f64,
56 pub max_depth: usize,
58 pub n_vars: usize,
60 pub p_var: f64,
62 pub const_std: f64,
64 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, }
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 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#[derive(Clone, Debug)]
221pub struct GaussianRegression {
222 pub xs: Vec<f64>,
224 pub ys: Vec<f64>,
226 pub noise: NoiseSpec,
228}
229
230#[derive(Clone, Debug)]
232pub enum NoiseSpec {
233 Fixed(f64),
235 Infer {
238 low: f64,
240 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 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
287pub fn subtree_crossover_mask() -> CrossoverMaskFn {
295 Box::new(|a: &Trace, b: &Trace, rng: &mut dyn rand::RngCore| {
296 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 #[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 assert!(trace.choices.keys().any(|a| a.as_str() == "node#leaf"));
356 }
357 }
358
359 #[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 #[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 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 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 let after_sets: Vec<usize> = particles.iter().map(|p| p.trace.choices.len()).collect();
438 let _ = (before_sets, after_sets); }
440
441 #[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 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 #[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 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 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 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 #[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, };
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 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 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 #[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, };
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, },
698 &mut kernel,
699 );
700 let model_fn = model.smc_model();
701 let (best, best_f) = result.best(&fitness, &model_fn).unwrap();
702 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}