1use fugue::{addr, sample, Address, Bernoulli, Categorical, Model, ModelExt, Normal, Trace};
43
44pub 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#[derive(Clone, Debug)]
54pub struct ArithmeticGrammarPrior {
55 pub terminal_prob: f64,
57 pub max_depth: usize,
59 pub n_vars: usize,
61 pub p_var: f64,
63 pub const_std: f64,
65 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, }
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 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#[derive(Clone, Debug)]
222pub struct GaussianRegression {
223 pub xs: Vec<f64>,
225 pub ys: Vec<f64>,
227 pub noise: NoiseSpec,
229}
230
231#[derive(Clone, Debug)]
233pub enum NoiseSpec {
234 Fixed(f64),
236 Infer {
239 low: f64,
241 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 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
288pub fn subtree_crossover_mask() -> CrossoverMaskFn {
296 Box::new(|a: &Trace, b: &Trace, rng: &mut dyn rand::RngCore| {
297 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 #[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 assert!(trace.choices.keys().any(|a| a.as_str() == "node#leaf"));
357 }
358 }
359
360 #[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 #[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 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 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 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 #[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 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 #[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 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 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 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 #[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, };
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 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 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 #[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, };
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, },
716 &mut kernel,
717 );
718 let model_fn = model.smc_model();
719 let (best, best_f) = result.best(&fitness, &model_fn).unwrap();
720 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}