This issue tracks one repo's half of a cross-repo initiative: making "evolutionary algorithms as probabilistic programs" architecturally true rather than merely documented (the honest-scoping work of EV-17 described the gap; this closes it).
The one-sentence conception. An evolutionary algorithm is tempered Sequential Monte Carlo in trace space: the genome prior is a user-written fugue Model<G> (a probabilistic program), fitness enters as factor(β·f(g)) so the Boltzmann target π_β ∝ p(x)·exp(β·f(x)) is itself a fugue program, selection is resampling, mutation is MH rejuvenation through fugue's typed proposals, and crossover is a population-coupled MH move on the product target.
The plan below was produced by a five-agent deep-recon/design/synthesis pass over both repositories (July 2026). Section numbering follows the full cross-repo plan; the sibling issue carries the other repo's work breakdown. The complete per-repo design spec (full API signatures, invariance arguments, test plans) is posted as the first comment on this issue.
This repo's scope: the two-layer refactor targeting fugue-evo 0.2.0 — Phase 0 trait split (TraceGenome) + ppl feature gate, Phase 1 GenomePrior (priors as programs), Phase 2 MH delegation to fugue::inference::mh (fixing the silent non-F64 dead-chain), Phase 3 EvolutionarySMC rebuilt on fugue SMC primitives + crossover as a population kernel, Phase 4 TreeGenome as a probabilistic grammar with subtree trace surgery and the flagship symbolic-regression-as-inference example, Phase 5 repositioning. Depends on upstream primitives from alexnodeland/fugue#44.
1. Executive summary
1.1 Current state — a three-tier integration that is stapled, not fused
fugue-evo today presents itself as a probabilistic genetic-algorithm library, but its coupling to the fugue PPL is ornamental. Verified by full recon: outside src/fugue_integration/, the only production (non-test) caller of to_trace/from_trace in the entire crate is CompositeGenome delegating to its two components. Every genome's to_trace builds a Trace::default() and calls insert_choice(addr, ChoiceValue::_, 0.0) — the Trace is used as a pure Address→ChoiceValue data container with every log-probability hard-coded to 0.0. No log-densities are ever computed on this path. The eight algorithm modules, all operators, population, fitness, hyperparameter, interactive, checkpoint, diagnostics, termination, and the entire ~5.9k-LOC wasm crate contain zero references to any fugue type. The trace round-trip is dead weight on the default path.
Inside fugue_integration, the "PPL" tier re-implements from scratch what fugue already provides, and does so with bugs. EvolutionModel hand-rolls log_prior_density over a closed two-variant Prior enum (UniformBounds | Gaussian). EvolutionStep is a hand-rolled Metropolis kernel whose propose only perturbs ChoiceValue::F64 and passes every other variant through unchanged (other => other.clone()) — so MH is a silent no-op for every BitString, Permutation, and Tree genome; those chains never move. EvolutionarySMC duplicates fugue's tempered SMC with a linear (not adaptive) β schedule and a weight-model bug: it reweights by dβ · p.fitness where p.fitness is raw f(x), treating β·f as the log-likelihood directly, which double-counts β relative to fugue's convention that the incremental weight comes from log_likelihood + log_factors tempered by β. The genuinely valuable pieces are narrow: TraceScoringHandler/RecordingHandler are real Handler impls, and crossover_sweep + swap_coordinates prototype a population-coupled move that fugue lacks entirely.
1.2 Target state
Two clean layers, one new upstream hook.
- A classic, fugue-free EC layer (
fugue_evo:: default): all algorithms (SimpleGA, CMA-ES, NSGA-II, Island, ES, EDA/UMDA, SteadyState), operators, populations, the wasm optimizer surface, checkpointing — none carry a fugue dependency.
- A fugue-native inference layer (
fugue_evo::inference::, behind an opt-in ppl feature): the genome prior is a user-written Model<G>; fitness is factor(β·f); MH rejuvenation delegates to fugue::inference::mh; tempered SMC is built on fugue::inference::smc primitives; genetic operators become trace surgery (block regeneration, subtree swap); the flagship is symbolic regression posed as exact Bayesian inference over a probabilistic-grammar trace.
- One new upstream primitive in fugue: a population-coupled SMC kernel hook (
PopulationKernel + adaptive_smc_with_kernel), plus supporting additive surface (block-regeneration MH, subtrace surgery, decode-replay helper, widened re-exports, and an internal fix so SMC rejuvenation moves non-F64 sites).
1.3 Conflicts between the two design specs — resolved here (recon wins)
| # |
Disagreement |
Resolution (rationale) |
| C1 |
fugue SemVer: fugue-upstream spec says the additions ship as 0.3.0; fugue-evo spec pins fugue-ppl = "0.2.1". |
Ship as fugue-ppl 0.2.1. Recon states the crate's own posture: 0.x.y → 0.x.(y+1) is additive/non-breaking. Every fugue change here is additive (new items + widened re-exports + a behavior-additive internal fix; no existing signature changes). Recon wins; 0.3.0 is over-signaling. |
| C2 |
Population-kernel trait shape: fugue-upstream spec = trait PopulationKernel<A> { fn sweep(&mut self, rng: &mut dyn RngCore, …, model_fn: &dyn Fn()->Model<A>, beta) } (object-safe); fugue-evo spec = trait PopulationKernel { fn apply<A, R: Rng>(…) } (generic method, not object-safe). |
Adopt the fugue-upstream PopulationKernel<A> + sweep + adaptive_smc_with_kernel. fugue owns the API; the upstream design deliberately keeps it object-safe. fugue-evo's apply/adaptive_smc_with_population_kernel names are superseded. |
| C3 |
Who owns CrossoverKernel: fugue-upstream spec ships a generic CrossoverKernel (takes a mask closure) inside fugue; fugue-evo spec says "fugue-evo provides the concrete CrossoverKernel." |
CrossoverKernel (generic, mask-closure-driven) lives in fugue; fugue-evo supplies the mask closures. Matches the fugue-spec §6 dividing line — fugue owns trace-space moves invariant to meaning; choosing which addresses form a block is genome knowledge and stays in fugue-evo. |
| C4 |
Trace-surgery method names: fugue-upstream = extract_prefix / truncate_prefix / graft_prefix + Address::has_prefix; fugue-evo = Trace::{extract, graft, truncate}. |
Use extract_prefix / truncate_prefix / graft_prefix + Address::has_prefix. Owner's names; the boundary-aware has_prefix is load-bearing (raw starts_with over-matches "gene"/"generation"). |
| C5 |
Whether Particle gains A. Both specs land on no; recon confirms it would be a breaking Particle-field change forcing A: Clone. |
Decode-replay helper, do not store A. Unanimous; recorded for completeness. |
2. Cross-repo sequencing (release-train view)
2.1 Release train
fugue-ppl 0.2.0 (current, published)
│
│ ── fugue-evo 0.1.1 depends on it unconditionally (no feature gate)
│
▼
fugue-ppl 0.2.1 (ADDITIVE — all new upstream primitives land here)
ships: F1 Fix 1b: SMC rejuvenation moves non-F64 sites
F2 block_regeneration_mh
F3 Trace::{extract_prefix,truncate_prefix,graft_prefix} + Address::has_prefix
F4 PopulationKernel<A> + NoKernel + CrossoverKernel + adaptive_smc_with_kernel
F5 decode_particle / decode_particles
F6 widened crate-root re-exports (SMCResult, smc_prior_particles,
normalize_particles, resample_particles, rejuvenate_particles,
score_given_trace_reconciled, ReconcileReport, + all F2/F4/F5 items)
│
▼
fugue-evo 0.2.0 (BREAKING-MINOR, pre-1.0 — consumes fugue-ppl 0.2.1, optional)
Phase 0 trait split (TraceGenome) + ppl feature gate ← needs only fugue 0.2.0
Phase 1 GenomePrior + EvolutionModel<P,F> ← needs fugue 0.2.0
Phase 2 EvolutionStep → EvolutionChain (fugue mh) ← needs F1 (0.2.1) for non-F64 movement
Phase 3 EvolutionarySMC rebuild + CrossoverKernel masks ← needs F4,F5,F6 (0.2.1)
Phase 4 TreeGenome grammar + subtree surgery + flagship ← needs F2,F3,F6 (0.2.1)
Phase 5 repositioning: classic/ vs inference/, docs, semver
2.2 Dependency edges between work items
fugue-evo Phase 0 ─────────────────────────────► everything downstream (must land first)
│
├──► Phase 1 (GenomePrior needs TraceGenome + PriorHandler/ScoreGivenTrace, all in 0.2.0)
│
fugue F1 ──────────────► fugue-evo Phase 2 (non-F64 chain movement is F1's fix)
Phase 1 ───────────────► fugue-evo Phase 2 (EvolutionChain wraps the target_model)
│
fugue F4,F5,F6 ────────► fugue-evo Phase 3 (SMC rebuild + decode-replay readouts)
Phase 1 ───────────────► fugue-evo Phase 3 (smc_model supplies factor(f))
fugue F3 ──────────────► fugue F4 (CrossoverKernel's swap_block is built on graft_prefix)
│
fugue F2,F3,F6 ────────► fugue-evo Phase 4 (subtree regen = block_regeneration_mh;
subtree crossover = graft_prefix + reconcile)
Phase 3 ───────────────► fugue-evo Phase 4 (grammar SMC reuses the Phase-3 driver)
│
all above ─────────────► fugue-evo Phase 5 (docs/rename/migration; no code deps)
2.3 What can proceed in parallel
- All of fugue 0.2.1 (F1–F6) can be built in parallel with fugue-evo Phase 0 and Phase 1, because Phases 0/1 only use fugue-0.2.0 surface (
TraceGenome, PriorHandler, ScoreGivenTrace, factor, plate!). Start the fugue F-work and the fugue-evo Phase-0/1 work on day one.
- Within fugue 0.2.1: F1 (rejuvenation fix), F2 (block MH), F5 (decode helper) are mutually independent. F4 depends on F3 (the crossover swap is built on
graft_prefix). F6 (re-exports) lands last, after F1–F5 exist.
- fugue-evo Phase 2 (mh wrapper) and the fugue-evo-side CrossoverKernel masks for Phase 3 can be drafted in parallel once Phase 1 is merged, but Phase 3's driver cannot land until fugue 0.2.1 is cut.
- Phase 4 grammar-prior authoring (the
Model<TreeGenome>) can begin in parallel with Phase 3, but its MH/crossover operators depend on fugue F2/F3.
3. Work breakdown — fugue-evo (all land in fugue-evo 0.2.0, pre-1.0 breaking-minor)
The DELETE / REWRITE / KEEP disposition for src/fugue_integration/ (renamed inference/):
| Item |
File:line |
Disposition |
Phase |
Size |
Prior enum |
evolution_model.rs:59-74 |
DELETE |
1 |
S |
log_prior_density |
:182-200 |
DELETE |
1 |
S |
prior_coordinate_model |
:216-239 |
DELETE → GenomePrior::model |
1 |
S |
log_boltzmann_target/log_weight/coords |
:203-209,168-170,173-175 |
REWRITE → total_log_weight() |
1 |
S |
EvolutionModel<G,F> + builders |
:81-158 |
REWRITE → EvolutionModel<P,F> |
1 |
M |
sample_prior |
:242-252 |
REWRITE → run(PriorHandler, prior.model()) returns G |
1 |
S |
to_weighted_trace |
:261-266 |
KEEP semantics (EV-52) |
1 |
S |
coords_of |
:270-280 |
REWRITE → diagnostic on TraceGenome |
1 |
S |
EvolutionChainConfig |
:284-326 |
REWRITE → thin config over DiminishingAdaptation+SiteProposal |
2 |
S |
EvolutionStep<G,F> |
:337-420 |
DELETE → fugue::inference::mh |
2 |
M ⚠ |
Particle<G> |
:424-448 |
DELETE → fugue Particle + decode-replay |
3 |
S |
EvolutionarySMC<G,F> |
:465-800 |
DELETE + REBUILD on fugue primitives |
3 |
L ⚠ |
linear_beta_schedule |
:507 |
DELETE (adaptive next_beta) |
3 |
S |
normalize_weights/effective_sample_size/resample |
:569-630 |
DELETE (fugue equivalents) |
3 |
S |
mutation_sweep |
:633-645 |
DELETE → rejuvenate_particles |
3 |
S |
crossover_sweep+swap_coordinates |
:654-705,805-834 |
REWRITE → mask closures feeding fugue CrossoverKernel |
3 |
L ⚠ |
weighted_mean/weighted_variance/best_particle |
:758-799,749-755 |
KEEP as readouts (decode-replay) |
3 |
S |
TraceScoringHandler |
effect_handlers.rs:52-134 |
KEEP (EV-52); may alias ScoreGivenTrace |
1 |
S |
RecordingHandler<H> |
:146-213 |
KEEP (EV-51) |
— |
— |
Hook types (MutationHook/…/Composed*) |
:216-598 |
DEMOTE to classic::observe or DELETE |
4 |
M |
hooked_mutate_trace/hooked_crossover_traces |
:609-752 |
DELETE/REWRITE as trace surgery (EV-51) |
4 |
M |
OperationStatistics |
:756-810 |
DELETE |
4 |
S |
BayesianAdaptiveGA + Beta/Gamma |
bayesian_ga.rs |
KEEP, REPOSITION to classic::; Beta/Gamma→rand_distr (EV-53) |
5 |
M |
trace_operators (selectors/masks/mutations) |
trace_operators.rs |
REWRITE → address-set selectors; delete logp=0.0 bodies (EV-54) |
4 |
M |
E0 — Phase 0: TraceGenome trait split + ppl feature gate
Repo: fugue-evo · Size: M · Risk: low · Depends on: nothing (uses fugue 0.2.0)
Files: src/genome/traits.rs, new src/genome/trace_genome.rs, the 5 leaf genome files (real_vector.rs, bit_string.rs, permutation.rs, dynamic_real_vector.rs, tree.rs), composite.rs, src/lib.rs, Cargo.toml, tests/property_tests.rs.
EvolutionaryGenome loses to_trace/from_trace/trace_prefix; the pub use fugue::ChoiceValue at traits.rs:9 relocates behind the gate.
// src/genome/traits.rs — classic, fugue-free
pub trait EvolutionaryGenome:
Clone + Send + Sync + Serialize + DeserializeOwned + 'static
{
type Allele: Clone + Send;
type Phenotype;
fn decode(&self) -> Self::Phenotype;
fn dimension(&self) -> usize;
fn generate<R: Rng>(rng: &mut R, bounds: &MultiBounds) -> Self;
fn as_slice(&self) -> Option<&[Self::Allele]> { None }
fn as_mut_slice(&mut self) -> Option<&mut [Self::Allele]> { None }
fn distance(&self, other: &Self) -> f64;
fn try_distance(&self, other: &Self) -> Result<f64, GenomeError>;
// to_trace / from_trace / trace_prefix REMOVED
}
// src/genome/trace_genome.rs — #[cfg(feature = "ppl")]
use fugue::{Address, Trace};
pub use fugue::ChoiceValue; // re-export relocated (was traits.rs:9)
pub trait TraceGenome: EvolutionaryGenome {
fn to_trace(&self) -> Trace;
fn from_trace(trace: &Trace) -> Result<Self, GenomeError>;
fn trace_prefix() -> &'static str { "gene" }
}
Leaf genomes: lift each to_trace/from_trace/trace_prefix body verbatim into #[cfg(feature="ppl")] impl TraceGenome for _. CompositeGenome is the one hard case — its EvolutionaryGenome impl keeps A,B: EvolutionaryGenome; its trace impl gains a tighter bound:
#[cfg(feature = "ppl")]
impl<A, B> TraceGenome for CompositeGenome<A, B>
where A: TraceGenome, B: TraceGenome { /* namespace_into / extract_namespace bodies */ }
TreeGenomeType (tree.rs:920-937) and the value sub-traits (RealValuedGenome/BinaryGenome/PermutationGenome) have no trace dependency — untouched.
Cargo:
[features]
default = ["std", "parallel", "checkpoint", "ppl"]
ppl = ["dep:fugue-ppl"]
[dependencies]
fugue-ppl = { path = "../fugue", version = "0.2.1", optional = true } # was unconditional 0.2.0
rand_distr = "0.4" # already present
Module tree after split: genome/ (classic) + genome/trace_genome.rs (ppl); algorithms/ operators/ population/ fitness/ hyperparameter/ interactive/ checkpoint/ diagnostics/ termination/ error/ (all classic); classic/ (Phase 5 home of BayesianAdaptiveGA); inference/ (renamed from fugue_integration, ppl) with prior.rs, model.rs, mh.rs, smc.rs, grammar.rs, effect_handlers.rs. lib.rs:136 becomes #[cfg(feature="ppl")] pub use inference::….
--no-default-features (minus ppl) compiles with fugue absent: all algorithms/operators/population/fitness/hyperparameter/interactive/checkpoint/diagnostics/termination/error + the core of every genome; the entire wasm crate unchanged (recon: zero fugue imports).
Acceptance: whole workspace builds with --no-default-features --features std,parallel,checkpoint (fugue absent) and with default (fugue present); per-genome test_*_trace_roundtrip MOVE under #[cfg(all(test, feature="ppl"))] and call via TraceGenome; tests/property_tests.rs trace cases (:47-53,101-107,132-140) GATED behind ppl, classic props ungated; all algorithms/operators/… tests UNCHANGED and green.
E1 — Phase 1: GenomePrior replaces the Prior enum
Repo: fugue-evo · Size: M · Risk: low–moderate · Depends on: E0
Load-bearing decision (resolves both specs): GenomePrior::model returns Model<G> (the decoded genome), not Model<Vec<f64>> + separate decode. Rationale: (1) the model's return value is the decode, so from_trace/to_trace become one generative function and decode-replay (F5) works for free; (2) Vec<f64> cannot express Bool (BitString) or Usize (Permutation) sites — fugue's five leaf types are exactly the ChoiceValue variants; (3) variable structure (DynamicRealVector length, TreeGenome shape) needs the return type to reflect run-dependent structure.
Files: new inference/prior.rs, inference/model.rs; delete/rewrite in evolution_model.rs; keep effect_handlers.rs.
// inference/prior.rs
pub trait GenomePrior: Clone + Send + Sync + 'static {
type Genome: TraceGenome;
/// A fugue program sampling the genome's canonical trace sites and returning
/// the assembled genome. Running under PriorHandler draws p(x) and accumulates
/// log_prior.
fn model(&self) -> Model<Self::Genome>;
}
// inference/model.rs
pub struct EvolutionModel<P, F>
where P: GenomePrior,
F: Fitness<Genome = P::Genome, Value = f64> + Clone + Send + Sync + 'static {
prior: P, fitness: F, beta: f64,
}
impl<P, F> EvolutionModel<P, F> /* bounds as above */ {
pub fn new(prior: P, fitness: F) -> Self { Self { prior, fitness, beta: 1.0 } }
pub fn with_beta(mut self, beta: f64) -> Self { self.beta = beta.max(0.0); self }
pub fn with_temperature(mut self, t: f64) -> Self {
self.beta = if t > 0.0 { 1.0 / t } else { f64::INFINITY }; self }
/// Fixed-β Boltzmann target log π_β = log p(x) + β·f(x). For MH.
pub fn target_model(&self) -> impl Fn() -> Model<P::Genome> + '_ {
let (prior, f, beta) = (self.prior.clone(), self.fitness.clone(), self.beta);
move || { let f = f.clone();
prior.model().bind(move |g| { let fit = f.evaluate(&g);
factor(beta * fit).map(move |_| g) }) } // factor node = model.rs:426
}
/// Untempered target: fitness enters at β=1 as a factor; SMC supplies β via
/// tempering, so β MUST NOT be baked in (fixes the double-counting bug).
pub fn smc_model(&self) -> impl Fn() -> Model<P::Genome> + '_ {
let (prior, f) = (self.prior.clone(), self.fitness.clone());
move || { let f = f.clone();
prior.model().bind(move |g| { let fit = f.evaluate(&g);
factor(fit).map(move |_| g) }) }
}
}
Weight-model fix (recon): factor(fit) lands in trace.log_factors; adaptive_smc tempers log_likelihood + log_factors by β, so β applies exactly once — fixing the old EvolutionarySMC reweighting by dβ · p.fitness with raw β·f.
Scoring — log_prior_density etc. delete; any point score becomes replay:
pub fn score(&self, g: &P::Genome) -> (P::Genome, Trace) {
run(ScoreGivenTrace { base: g.to_trace(), trace: Trace::default() }, (self.target_model())())
}
// log π_β = total_log_weight(); log p = log_prior; β·f = log_factors
The uniform-box −∞-outside behavior is now inherited from sample(addr, Uniform::new(lo,hi)) scored under ScoreGivenTrace (out-of-support → -inf logp), not a hand match.
Constructors (plate! lowers to traverse_vec, O(1)-stack, macros/mod.rs:81; addresses match existing to_trace schemes):
#[derive(Clone)] pub struct UniformBoxPrior { bounds: MultiBounds } // was Prior::UniformBounds
impl GenomePrior for UniformBoxPrior {
type Genome = RealVector;
fn model(&self) -> Model<RealVector> {
let bounds = self.bounds.clone();
plate!(i in 0..bounds.dimension() => {
let b = bounds.get(i).unwrap();
sample(addr!("gene", i), Uniform::new(b.min, b.max).unwrap())
}).map(RealVector::from_genes)
}
}
#[derive(Clone)] pub struct GaussianPrior { mean: f64, std: f64, dim: usize } // was Prior::Gaussian
impl GenomePrior for GaussianPrior {
type Genome = RealVector;
fn model(&self) -> Model<RealVector> {
let (mean, std, dim) = (self.mean, self.std, self.dim);
plate!(i in 0..dim => sample(addr!("gene", i), Normal::new(mean, std).unwrap()))
.map(RealVector::from_genes)
}
}
Add a small classic RealVector::from_genes(Vec<f64>) -> RealVector. Variable-length priors (e.g. a geometric-length DynamicRealVector) are expressible as genuine generative programs — a Categorical length site at addr!("meta","len") then a plate! of that-many coordinates — replacing the old inert meta/min_length/max_length I64 choices with a real log_prior term; fugue's MH handles the resulting births/deaths with the dim_term correction automatically.
Flag: the fitness closure must be Clone + Send + 'static to satisfy bind's k: FnOnce + Send + 'static (model.rs:20-131); Fitness already bounds Send + Sync + 'static, so require F: Clone.
Acceptance: EV-52 test_to_weighted_trace_carries_fitness_mass (:869) + test_trace_scoring_handler_injects_factor (effect_handlers.rs:824) + e_integration_weighted_trace_is_boltzmann_weight (e_integration_bayesian.rs:77) — total_log_weight() == β·f, mass in log_factors — KEEP verbatim; test_sample_prior_gaussian_uses_fugue_model (:1025) REPLACE with run(PriorHandler, GaussianPrior::model()) returning RealVector, assert moments; new test_prior_model_log_prior_matches_analytic (Gaussian: scored.log_prior == Σ Normal::log_prob) as the anchor replacing deleted log_prior_density.
E2 — Phase 2: EvolutionStep → EvolutionChain over fugue::inference::mh
Repo: fugue-evo · Size: M · Risk: moderate · Depends on: E1, fugue F1
Files: inference/mh.rs; delete EvolutionStep/EvolutionChainConfig from evolution_model.rs.
pub struct EvolutionChain<P, F> {
model: EvolutionModel<P, F>,
adaptation: DiminishingAdaptation, // fugue mcmc_utils.rs:30
overrides: HashMap<Address, SiteProposal>, // fugue mh.rs:146, per-address f64 override
}
impl<P, F> EvolutionChain<P, F>
/* P: GenomePrior, F: Fitness<Genome=P::Genome, Value=f64> + Clone */ {
pub fn new(model: EvolutionModel<P, F>) -> Self {
Self { model, adaptation: DiminishingAdaptation::new(0.234, 0.7), overrides: HashMap::new() } }
pub fn target_rate(mut self, r: f64) -> Self { self.adaptation = DiminishingAdaptation::new(r, 0.7); self }
pub fn override_site(mut self, a: Address, p: SiteProposal) -> Self { self.overrides.insert(a, p); self }
/// One π_β-invariant transition; moves ANY site type (fixes the F64-only bug).
pub fn step<R: Rng>(&mut self, rng: &mut R, current: &Trace) -> (P::Genome, Trace) {
adaptive_single_site_mh(rng, self.model.target_model(), current, &mut self.adaptation)
}
/// Warmup-then-frozen chain; returns decoded genomes + traces.
pub fn run_chain<R: Rng>(&self, rng: &mut R, init: &Trace, n: usize, warmup: usize)
-> Vec<(P::Genome, Trace)> {
adaptive_mcmc_chain_with_overrides(rng, self.model.target_model(), n, warmup, &self.overrides)
}
}
adaptive_single_site_mh picks the target uniformly over current.choices.keys() and dispatches by ChoiceValue type (mh.rs:837-838), so it moves Bool/Usize/U64/I64 — this is the fix. EvolutionChainConfig collapses to the two setters.
Proposal ↔ site-type map: gene#i F64 → GaussianWalkProposal (override to Reflect{lo,hi}/LogSpace); bit#i Bool → FlipProposal; perm#i Usize → prior-resample inline (no ProposalStrategy<usize>, mh.rs:516-530); meta#len Usize → prior-resample triggers trans-dim birth/death with dim_term.
Permutation caveat (flag, Phase-1 design task that makes Phase-2 correct): single-site prior-resample of one perm#i breaks the permutation invariant unless the prior model enforces it. The correct encoding is a Fisher–Yates / Lehmer-code prior — perm#i ~ Categorical(remaining) — so every trace decodes to a valid permutation and single-site resample stays in support. Author PermutationPrior::model this way in E1.
Acceptance: EV-90 test_mh_respects_bounds (:883, truncated-exponential mean ≈ 1.0746 on [-2,2]) + e_integration_mh_stays_in_bounds (:88) — KEEP assertion, re-drive via EvolutionChain::run_chain (support from the bounded prior under ScoreGivenTrace); new must-add anchors test_bitstring_chain_moves (≥1 accepted bit-flip over N steps — was 0 under the old other => other.clone()) and test_permutation_chain_moves (decoded permutation changes under the Lehmer prior); delete EvolutionStep::propose unit tests.
E3 — Phase 3: EvolutionarySMC rebuild + crossover masks
Repo: fugue-evo · Size: L · Risk: high · Depends on: E1, fugue F4, F5, F6
Files: inference/smc.rs; delete the hand-rolled loop in evolution_model.rs.
Delete linear_beta_schedule, normalize_weights, effective_sample_size, resample, mutation_sweep, Particle<G>. Keep weighted_mean/weighted_variance/best_particle as readouts (recompute G via decode-replay). Rewrite crossover_sweep+swap_coordinates as mask closures passed to fugue's CrossoverKernel (C3 resolution).
pub struct EvoSmcConfig {
pub num_particles: usize,
pub ess_threshold: f64,
pub resampling: ResamplingMethod,
pub rejuvenation_steps: usize,
pub crossover: Option<CrossoverMaskCfg>, // None = per-particle rejuvenation only
}
pub struct EvolutionResult<G> {
pub particles: Vec<Particle>, // fugue Particle (trace only)
pub log_evidence: f64, // log Ẑ (FG-58)
_g: PhantomData<G>,
}
impl<G: TraceGenome> EvolutionResult<G> {
pub fn genome(&self, p: &Particle, model_fn: &impl Fn() -> Model<G>) -> G {
decode_particle(p, model_fn) // fugue F5
}
pub fn weighted_mean(&self, coord: usize) -> f64 { /* Σ wᵢ · gene#coord(pᵢ.trace) */ }
pub fn weighted_variance(&self, coord: usize) -> f64 { /* … */ }
pub fn best(&self, f: &impl Fitness<Genome=G,Value=f64>, model_fn: &impl Fn()->Model<G>) -> (G, f64) { /* argmax */ }
pub fn best_fitness(&self, /* … */) -> f64 { /* optimizer-mode readout for benchmarking */ }
}
pub fn run<P, F, R: Rng>(rng: &mut R, model: &EvolutionModel<P,F>, cfg: EvoSmcConfig)
-> EvolutionResult<P::Genome>
where P: GenomePrior, F: Fitness<Genome=P::Genome, Value=f64> + Clone {
let model_fn = model.smc_model(); // factor(f), NOT factor(β·f)
let smc_cfg = SMCConfig { resampling_method: cfg.resampling,
ess_threshold: cfg.ess_threshold,
rejuvenation_steps: cfg.rejuvenation_steps };
match cfg.crossover {
None => { let r = adaptive_smc(rng, cfg.num_particles, &model_fn, smc_cfg);
EvolutionResult { particles: r.particles, log_evidence: r.log_evidence, _g: PhantomData } }
Some(mcfg) => {
let mut kernel = CrossoverKernel { n_pairs: mcfg.n_pairs, mask: mcfg.into_mask() };
let r = adaptive_smc_with_kernel(rng, cfg.num_particles, &model_fn, smc_cfg, &mut kernel);
EvolutionResult { particles: r.particles, log_evidence: r.log_evidence, _g: PhantomData }
}
}
}
The mask closures (single-point / two-point / uniform over the genome's fixed address set) are the surviving interface concept of the old crossover_sweep; they return the address block to swap and satisfy the value-independent, pair-symmetric contract.
Acceptance: EV-16 test_smc_matches_gaussian_conjugate_posterior (:924, N(0,4) prior + f=-0.5(x-3)² ⇒ mean 2.4, var 0.8) + mirror e_integration_smc_targets_conjugate_posterior (:31, center 2 ⇒ mean 1.6, var 0.8) — KEEP verbatim, re-drive via run + weighted_mean/weighted_variance, tolerances ±0.15 mean / ±0.2 var. This is the Phase-3 gate and directly exercises the β-single-counting fix. test_smc_basic_normalized (:987) REWRITE → assert fugue normalize_particles invariant on EvolutionResult.particles; test_particle_resampling (:1001) REPLACE → fugue resample_particles (or delete; upstream owns it); new test_crossover_kernel_preserves_uniform_weights (FG-13).
E4 — Phase 4: TreeGenome as a probabilistic grammar + subtree surgery + flagship
Repo: fugue-evo · Size: L · Risk: high · Depends on: E3, fugue F2, F3, F6
Files: new inference/grammar.rs; rewrite trace_operators.rs; TreeGenome trace impl; new examples/symbolic_regression_inference.rs; extend Terminal/Function traits.
Tree-path addresses (replace the flat preorder integer index at tree.rs:703-715,764-860). Each node at path p=[i0,i1,…] under key P = "node/" + p.join("/"):
| Site |
Address |
Dist / ChoiceValue |
| terminal-vs-function |
addr!(P, "is_terminal") |
Bernoulli(terminal_prob) → Bool |
| function choice |
addr!(P, "func") |
Categorical(uniform over F::functions()) → Usize |
| terminal type |
addr!(P, "term_type") |
Categorical → Usize |
| constant / ERC value |
addr!(P, "const") |
Normal/Uniform → F64 |
Children recurse at P/0, P/1, … up to Function::arity (:384). Descendants of p form a contiguous lexicographic key range sharing prefix "node/"+p.join("/") — this is what makes subtree crossover a subtrace swap.
Grammar prior (to_trace becomes a real PCFG replay, so log_prior accumulates genuine grammar log-prob; sequence_vec keeps O(1)-stack):
#[derive(Clone)]
pub struct GrammarPrior<T: Terminal, F: Function> { terminal_prob: f64, max_depth: usize, _p: PhantomData<(T,F)> }
impl<T, F> GenomePrior for GrammarPrior<T, F> where T: Terminal, F: Function {
type Genome = TreeGenome<T, F>;
fn model(&self) -> Model<TreeGenome<T, F>> { self.node_model(vec![], 0).map(TreeGenome::from_root) }
}
impl<T, F> GrammarPrior<T, F> {
fn node_model(&self, path: Vec<usize>, depth: usize) -> Model<TreeNode<T, F>> {
let p = path_key(&path); let force_terminal = depth >= self.max_depth;
sample(addr!(p.clone(), "is_terminal"),
Bernoulli::new(if force_terminal {1.0} else {self.terminal_prob}).unwrap())
.bind(move |is_term| if is_term { self.terminal_model(&p) }
else {
sample(addr!(p.clone(), "func"), Categorical::uniform(F::functions().len()).unwrap())
.bind(move |fi| { let arity = F::functions()[fi].arity();
let kids = (0..arity).map(|c| self.node_model([path.clone(), vec![c]].concat(), depth+1));
sequence_vec(kids.collect()).map(move |cs| TreeNode::function(fi, cs)) })
})
}
}
Terminal/Function gain one method each exposing the site distribution (fn site_dist() -> Categorical; Function::arity already exists).
Subtree operators via fugue's reconcile engine:
- Subtree regeneration mutation =
block_regeneration_mh (fugue F2) with block = every address under "node/"+p. Removed sites are redrawn from the grammar prior; prior terms cancel in the acceptance ratio (dim_term + log_q_fwd/rev, FG-20/21). No bespoke acceptance math.
- Subtree crossover =
graft_prefix (fugue F3) of parent-B's "node/"+p subtrace into parent-A, then re-score via score_given_trace_reconciled (mandatory: accumulators aren't address-decomposable). This is the Phase-3 population kernel specialized to trees.
trace_operators.rs rewrite: MutationSelector/CrossoverMask become address-set selectors returning the prefix S (the select_sites interface survives; insert_choice(.., 0.0) bodies delete, EV-54). TreeGenome struct fields unchanged → checkpoint compat holds (only the trace format changes; traces aren't serialized).
Flagship — examples/symbolic_regression_inference.rs (replaces the standalone hand-rolled GP loop, symbolic_regression.rs):
- Prior:
GrammarPrior<ArithmeticTerminal, ArithmeticFunction> over expression trees.
- Likelihood as factor: dataset
{(xₖ,yₖ)}; f(tree) = −0.5/σ²·Σ(tree.eval(xₖ)−yₖ)² bound via factor(f(tree)) in smc_model, so p(data|tree) ∝ exp(f) and the posterior π ∝ p_grammar(tree)·p(data|tree) is a fugue program. Parsimony emerges from the grammar prior (deeper trees cost prior mass), not an ad-hoc penalty.
- Inference:
run with EvoSmcConfig { crossover: Some(subtree-crossover mask), rejuvenation_steps: subtree-regeneration MH }, adaptive β from prior to β=1.
- Readouts:
best (MAP program), weighted_mean of predictions (posterior predictive), log_evidence (Bayesian model score for comparing function sets), best_fitness (optimizer-mode benchmark vs classic GP).
Acceptance: per-tree test_tree_trace_roundtrip REPLACE → test_grammar_trace_has_real_log_prior (log_prior == Σ grammar log-probs, not 0.0); EV-51 test_hooked_mutate_does_not_copy_stale_logp (:890) REPLACE → test_subtree_regen_scores_fresh_logp (reconcile re-scores, FG-48); EV-54 test_gaussian_mutation_achieves_sigma/test_bounded_mutation_respects_bounds (:450,481) KEEP for classic value operators + ADD test_subtree_crossover_swaps_prefix_range and test_subtree_regen_prior_cancels_in_ratio; new flagship must-add anchor test_symreg_recovers_known_expression (fit x²+1 from noiseless data, MAP tree evaluates correctly). Watch FG-47 — a graft that revisits an address panics.
E5 — Phase 5: repositioning, docs, semver
Repo: fugue-evo · Size: M · Risk: low · Depends on: E0–E4 (no code deps)
- Module rename
fugue_integration → inference (all #[cfg(feature="ppl")]). Surviving BayesianAdaptiveGA + hooks move to classic::. Prelude splits: fugue_evo::prelude (classic, fugue-free) and fugue_evo::inference::prelude (gated).
BayesianAdaptiveGA reposition (EV-53): it uses conjugate Beta/Gamma as Thompson samplers and EvolutionStep::propose as a mutation utility (:258-277). Two forced edits: repoint :277 step.propose(parent, rng) to a classic mutation operator or EvolutionChain::step (this also fixes its current real-only limitation); swap fugue Beta/Gamma → rand_distr::{Beta,Gamma} (precedent: hyperparameter/bayesian.rs:23). EV-53 tests (bayesian_ga.rs:353,365,390,399,439) KEEP unchanged (assert conjugate moments / Thompson preference, backend-independent).
- Docs: rewrite
docs/src/architecture/fugue-integration.md (recon: already stale — documents non-existent ConditioningHandler/ResamplingHandler/UniformPrior); move the to_trace/from_trace contract in custom-genome.md/reference/genomes.md into a TraceGenome/ppl section; supersede SPEC.md; add a new "Evolution as inference" page (GenomePrior, Boltzmann-target-as-program, the symbolic-regression flagship). Tagline → "Two layers: classical evolutionary algorithms (standalone) and evolutionary inference — evolutionary algorithms as probabilistic programs (tempered SMC in trace space, on fugue)."
- Migration guide (0.1.x → 0.2.0): (1) default still includes
ppl; only --no-default-features builds lose the inference path. (2) to_trace/from_trace/trace_prefix now require use fugue_evo::genome::TraceGenome; custom genome impls split into impl EvolutionaryGenome + impl TraceGenome. (3) ChoiceValue re-export moved behind ppl. (4) Prior::Gaussian{..} → GaussianPrior::new(mean,std,dim); Prior::UniformBounds → UniformBoxPrior::new(bounds). (5) EvolutionStep::new(..).run_chain(..) → EvolutionChain::new(EvolutionModel::new(prior, fitness)).run_chain(rng,&init,n,warmup). (6) EvolutionarySMC → EvolutionSMC::run(rng,&model,EvoSmcConfig{..}); readouts move from Particle<G> fields to EvolutionResult::{weighted_mean,best,log_evidence}. (7) BayesianAdaptiveGA → fugue_evo::classic::BayesianAdaptiveGA.
Acceptance: cargo doc clean; migration-guide example snippets compile as doc-tests; wasm crate builds unchanged (E0 already proves this).
4. Risk register (cross-repo)
| Risk |
Where |
Likelihood |
Impact |
Mitigation |
Trace-replay perf vs Vec<f64> hot loops |
E3/E4: decode-replay costs one model run per decode; ScoreGivenTrace per rejuvenation; crossover re-scores both children |
High (it is inherently costlier than array math) |
Med |
It's the same O(model-size) cost class SMC already pays per rejuvenation step; decode only at readout time, not per generation. Keep the classic layer for pure optimization (Phase-5 best_fitness benchmark quantifies the gap). Batch decodes; reuse run's O(1)-stack trampoline (FG-19). Do not store A on Particle (would force A: Clone + clone-on-resample, worse). |
| Population-kernel invariance / weight rules |
fugue F4 |
Med |
High (silently biased SMC) |
Enforce the four-part contract (W/T/S/E) in docs + tests. test_crossover_preserves_uniform_weights (bit-identical log_weight), test_crossover_evidence_noncorruption (vs NoKernel), and the conjugate product-target validation are gating. v1 restricts to fixed-structure genomes where ScoreGivenTrace is exact. |
| β double-counting regression |
E1/E3 |
Med |
High (wrong posterior) |
smc_model injects factor(f) (not β·f); β comes only from tempering. EV-16 is the gate. Code review the two builders (target_model uses β; smc_model does not). |
| Permutation single-site resample leaves support |
E2 |
Med |
High (invalid genomes / dead chain "fix" that's incorrect) |
Lehmer-code PermutationPrior::model (perm#i ~ Categorical(remaining)), authored in E1; test_permutation_chain_moves asserts valid decoded permutations. |
| RJMCMC acceptance for variable-structure trees |
E4 |
Med–High |
High |
Reuse fugue's already-built score_given_trace_reconciled + dim_term (F2); Geweke reversibility test; FG-47 vigilance (dup-address graft panics — use reconciling re-score to detect). test_subtree_regen_prior_cancels_in_ratio. |
| FG-58 log-evidence corruption by the new kernel |
fugue F4 |
Low–Med |
High (wrong model scores) |
Kernel has no evidence access (contract E); invariant moves add zero incremental weight. test_crossover_evidence_noncorruption. |
| Checkpoint compat |
E0–E4 |
Low |
Med |
No trace is ever serialized; CHECKPOINT_VERSION=1 preserved through Phase 0–3 (trait split + inference rewrite touch no genome fields). Phase 4 keeps TreeGenome fields unchanged (grammar reads existing TreeNode structure) → no bump; only a field change would force version 2 + shim. |
| WASM blast radius |
E0–E5 |
Very low |
Med |
Verified zero fugue imports in the wasm crate; every optimizer targets classic modules + value sub-traits, never TraceGenome. Compiles unchanged with ppl on or off. getrandom js feature and [profile.release] untouched. |
| Website / explorables regression |
E5 |
Very low |
Low |
evo.fugue.run explorables (CMA-ES/NSGA-II/island/UMDA/GA-operators) run on standalone algorithms — insulated. Editorial-math redesign (fugue#42/evo#16, MERGED & LIVE) unaffected. Doc rewrites are additive; a new "evolution as inference" explorable is a later opportunity, not a migration requirement. |
| SemVer breakage — fugue |
fugue 0.2.1 |
Low |
Low |
All changes additive (new items + widened re-exports + behavior-additive F1). Fits 0.2.0 → 0.2.1 per the crate's stated pre-1.0 posture. No Particle field change (decode-replay avoids the 0.3.0 bump). |
| SemVer breakage — fugue-evo |
fugue-evo 0.2.0 |
Certain (intended) |
Med |
Pre-1.0 breaking-minor; tag every change **(breaking)** in CHANGELOG (Keep-a-Changelog, continue EV-xx after EV-106). Real-world blast radius minimal: fugue_integration's only external consumers are in-repo (examples/bayesian_evolution.rs, tests/e_integration_bayesian.rs). Ship the §3.B.E5 migration guide. |
| fugue-evo depends on unreleased fugue 0.2.1 |
sequencing |
Med |
Med |
Path dependency during development; only Phases 3/4 need 0.2.1. Cut fugue 0.2.1 before merging E3. Phases 0–2 (except F1's benefit in E2) work on 0.2.0. |
Fitness: Clone bound propagation |
E1 |
Low |
Low |
bind's continuation requires FnOnce + Send + 'static; Fitness already bounds Send+Sync+'static, so only add Clone. Audit all Fitness impls compile. |
5. Validation strategy
5.1 The analytic-test ladder (fugue's known-answer convention throughout)
Rung 0 — fugue unit invariants (0.2.1):
- F1:
test_smc_rejuvenation_moves_bitstring (population not frozen; marginal bit-means match analytic Bernoulli posterior).
- F3:
test_extract_prefix_boundary, test_graft_round_trip, test_graft_rescore_equality, test_extract_zeroes_accumulators.
- F5:
test_decode_fidelity, test_decode_weighted_mean.
Rung 1 — conjugate posteriors (the core anchors):
- F2:
test_block_regen_beta_bernoulli (closed-form Beta posterior, 2 MC-SE, validation.rs:144).
- F4:
test_crossover_product_invariance (two independent conjugate-Normal, product target, validate_against_analytical_posterior, validation.rs:173).
- EV-16 (E3): conjugate Gaussian posterior mean 2.4 / var 0.8 (±0.15 / ±0.2) through the fugue-backed
run — the single most important carryover, proving the rebuild targets the same Boltzmann posterior and the β fix.
Rung 2 — mechanism invariants:
- EV-52 (E1):
total_log_weight() == β·f, mass in log_factors (KEEP verbatim).
- EV-90 (E2): truncated-exponential mean ≈ 1.0746 on [-2,2] through
EvolutionChain (support enforced by bounded prior under ScoreGivenTrace).
- F4 contract guards:
test_crossover_preserves_uniform_weights (W), test_crossover_evidence_noncorruption (E).
Rung 3 — the dead-chain fix (new, no prior test existed):
test_bitstring_chain_moves, test_permutation_chain_moves (E2) — prove the verified EvolutionStep::propose F64-only no-op is fixed for non-real genomes.
Rung 4 — structural correctness:
- F2
test_block_regen_transdimensional (Geweke reversibility), test_block_vs_sequential_single_site (KS).
- E4
test_grammar_trace_has_real_log_prior, test_subtree_regen_prior_cancels_in_ratio, test_subtree_crossover_swaps_prefix_range.
Cross-cutting guards to keep green every phase: EV-51 (RecordingHandler bookkeeping; hooked_mutate fresh-logp → test_subtree_regen_scores_fresh_logp), EV-53 (Beta/Gamma conjugate moments, backend-independent), EV-54 (classic value-mutation sigma/bounds), plus fugue's own FG regression suite (FG-03/13/43/58 for SMC; FG-20/21/47/48 for MH/reconcile).
5.2 Flagship end-to-end proof — symbolic regression as exact Bayesian inference
The demo (examples/symbolic_regression_inference.rs, E4) is the end-to-end argument that the conception holds:
- Setup: target function
y = x² + 1, noiseless dataset {(xₖ, yₖ)} on a grid. Prior GrammarPrior<ArithmeticTerminal, ArithmeticFunction> over expression trees (PCFG with real grammar log-probs). Likelihood f(tree) = −0.5/σ²·Σ(tree.eval(xₖ)−yₖ)² bound via factor(f(tree)).
- Claim demonstrated: the posterior
π(tree) ∝ p_grammar(tree)·exp(f(tree)) is a fugue program — nothing evolutionary-specific in the inference engine; fugue only executes it. Selection = SMC resampling; mutation = subtree block-regeneration MH (fugue F2); crossover = subtree subtrace-swap population kernel (fugue F3/F4).
- Acceptance test
test_symreg_recovers_known_expression: the MAP program (EvolutionResult::best) evaluates to x²+1 on held-out points (parsimony from the grammar prior, no ad-hoc penalty). Report log_evidence as the Bayesian model score and best_fitness as the optimizer-mode benchmark against the classic GP loop.
- Why it is the proof: it exercises variable-structure RJMCMC (tree births/deaths), the reconcile engine's prior cancellation, subtrace surgery, adaptive β tempering, decode-replay readouts, and log-evidence — every new primitive at once, against a known answer.
6. NOT-doing list
Stays in fugue-evo, fugue-free (classic layer):
- All PPL-free algorithms: SimpleGA, CMA-ES, NSGA-II, Island, ES, EDA/UMDA, SteadyState, and the entire wasm optimizer surface. They carry no probabilistic-program content and must not pull a
fugue dependency (Phase-5 "classic module").
EvolutionaryGenome / TraceGenome, all genome types, to_trace/from_trace. The genome↔trace mapping (addr!("gene",i), addr!("bit",i), tree-path naming, CompositeGenome namespacing) is a domain encode/decode convention.
- Fitness functions and Boltzmann-target assembly.
factor(β·f) is fugue's hook; the Fitness abstraction, the EvolutionModel bundle, and the "fitness as likelihood" convention (to_weighted_trace, EV-52) are application semantics.
- Crossover masks, mutation selectors, PCFG tree grammars. Which addresses form a "block," which mask, arity-weighted grammar priors — genome knowledge. fugue-evo supplies the mask closures; fugue's
CrossoverKernel/block_regeneration_mh take an address set/closure.
- Adaptive operator selection / Thompson sampling (
BayesianAdaptiveGA, Beta/Gamma as plain rand_distr samplers) — an operator-selection heuristic, not inference; lives in classic::.
- Fitness caching and cached-
G on particles — resolved via decode-replay + a fugue-evo-side cache.
- Checkpoint/serde formats and RNG snapshotting — bit-identical resume over
serde-of-G + ChaCha snapshots; no trace serialized.
fugue refuses to absorb (stays generic, keyed by address + value type only):
- Any evolutionary or genome semantics — fugue must not know what a "gene," "bit," or "tree grammar" is.
- A
Fitness trait — fugue provides factor, not a fitness abstraction.
- A checkpoint format.
- Storing the decoded
A on Particle — decode-replay (F5) instead, keeping Particle { trace, weight, log_weight } stable and additive.
- Variable-dimension crossover invariance in v1
CrossoverKernel — restricted to fixed-structure genomes; the trans-dimensional case is documented as a custom-kernel exercise using score_given_trace_reconciled, deferred.
The dividing line: fugue owns trace-space operations invariant to what the trace means (population kernels, block regeneration, subtrace surgery, decode-replay, tempered typed MH); fugue-evo owns what the trace means (genomes, fitness, operators, grammars, algorithms).
This issue tracks one repo's half of a cross-repo initiative: making "evolutionary algorithms as probabilistic programs" architecturally true rather than merely documented (the honest-scoping work of EV-17 described the gap; this closes it).
The plan below was produced by a five-agent deep-recon/design/synthesis pass over both repositories (July 2026). Section numbering follows the full cross-repo plan; the sibling issue carries the other repo's work breakdown. The complete per-repo design spec (full API signatures, invariance arguments, test plans) is posted as the first comment on this issue.
This repo's scope: the two-layer refactor targeting
fugue-evo 0.2.0— Phase 0 trait split (TraceGenome) +pplfeature gate, Phase 1GenomePrior(priors as programs), Phase 2 MH delegation tofugue::inference::mh(fixing the silent non-F64 dead-chain), Phase 3EvolutionarySMCrebuilt on fugue SMC primitives + crossover as a population kernel, Phase 4TreeGenomeas a probabilistic grammar with subtree trace surgery and the flagship symbolic-regression-as-inference example, Phase 5 repositioning. Depends on upstream primitives from alexnodeland/fugue#44.1. Executive summary
1.1 Current state — a three-tier integration that is stapled, not fused
fugue-evo today presents itself as a probabilistic genetic-algorithm library, but its coupling to the fugue PPL is ornamental. Verified by full recon: outside
src/fugue_integration/, the only production (non-test) caller ofto_trace/from_tracein the entire crate isCompositeGenomedelegating to its two components. Every genome'sto_tracebuilds aTrace::default()and callsinsert_choice(addr, ChoiceValue::_, 0.0)— theTraceis used as a pureAddress→ChoiceValuedata container with every log-probability hard-coded to0.0. No log-densities are ever computed on this path. The eight algorithm modules, all operators, population, fitness, hyperparameter, interactive, checkpoint, diagnostics, termination, and the entire ~5.9k-LOC wasm crate contain zero references to any fugue type. The trace round-trip is dead weight on the default path.Inside
fugue_integration, the "PPL" tier re-implements from scratch what fugue already provides, and does so with bugs.EvolutionModelhand-rollslog_prior_densityover a closed two-variantPriorenum (UniformBounds | Gaussian).EvolutionStepis a hand-rolled Metropolis kernel whoseproposeonly perturbsChoiceValue::F64and passes every other variant through unchanged (other => other.clone()) — so MH is a silent no-op for every BitString, Permutation, and Tree genome; those chains never move.EvolutionarySMCduplicates fugue's tempered SMC with a linear (not adaptive) β schedule and a weight-model bug: it reweights bydβ · p.fitnesswherep.fitnessis rawf(x), treatingβ·fas the log-likelihood directly, which double-counts β relative to fugue's convention that the incremental weight comes fromlog_likelihood + log_factorstempered by β. The genuinely valuable pieces are narrow:TraceScoringHandler/RecordingHandlerare realHandlerimpls, andcrossover_sweep+swap_coordinatesprototype a population-coupled move that fugue lacks entirely.1.2 Target state
Two clean layers, one new upstream hook.
fugue_evo::default): all algorithms (SimpleGA, CMA-ES, NSGA-II, Island, ES, EDA/UMDA, SteadyState), operators, populations, the wasm optimizer surface, checkpointing — none carry a fugue dependency.fugue_evo::inference::, behind an opt-inpplfeature): the genome prior is a user-writtenModel<G>; fitness isfactor(β·f); MH rejuvenation delegates tofugue::inference::mh; tempered SMC is built onfugue::inference::smcprimitives; genetic operators become trace surgery (block regeneration, subtree swap); the flagship is symbolic regression posed as exact Bayesian inference over a probabilistic-grammar trace.PopulationKernel+adaptive_smc_with_kernel), plus supporting additive surface (block-regeneration MH, subtrace surgery, decode-replay helper, widened re-exports, and an internal fix so SMC rejuvenation moves non-F64 sites).1.3 Conflicts between the two design specs — resolved here (recon wins)
0.3.0; fugue-evo spec pinsfugue-ppl = "0.2.1".fugue-ppl 0.2.1. Recon states the crate's own posture:0.x.y → 0.x.(y+1)is additive/non-breaking. Every fugue change here is additive (new items + widened re-exports + a behavior-additive internal fix; no existing signature changes). Recon wins;0.3.0is over-signaling.trait PopulationKernel<A> { fn sweep(&mut self, rng: &mut dyn RngCore, …, model_fn: &dyn Fn()->Model<A>, beta) }(object-safe); fugue-evo spec =trait PopulationKernel { fn apply<A, R: Rng>(…) }(generic method, not object-safe).PopulationKernel<A>+sweep+adaptive_smc_with_kernel. fugue owns the API; the upstream design deliberately keeps it object-safe. fugue-evo'sapply/adaptive_smc_with_population_kernelnames are superseded.CrossoverKernel: fugue-upstream spec ships a genericCrossoverKernel(takes amaskclosure) inside fugue; fugue-evo spec says "fugue-evo provides the concrete CrossoverKernel."CrossoverKernel(generic, mask-closure-driven) lives in fugue; fugue-evo supplies the mask closures. Matches the fugue-spec §6 dividing line — fugue owns trace-space moves invariant to meaning; choosing which addresses form a block is genome knowledge and stays in fugue-evo.extract_prefix / truncate_prefix / graft_prefix+Address::has_prefix; fugue-evo =Trace::{extract, graft, truncate}.extract_prefix / truncate_prefix / graft_prefix+Address::has_prefix. Owner's names; the boundary-awarehas_prefixis load-bearing (rawstarts_withover-matches"gene"/"generation").ParticlegainsA. Both specs land on no; recon confirms it would be a breakingParticle-field change forcingA: Clone.A. Unanimous; recorded for completeness.2. Cross-repo sequencing (release-train view)
2.1 Release train
2.2 Dependency edges between work items
2.3 What can proceed in parallel
TraceGenome,PriorHandler,ScoreGivenTrace,factor,plate!). Start the fugue F-work and the fugue-evo Phase-0/1 work on day one.graft_prefix). F6 (re-exports) lands last, after F1–F5 exist.Model<TreeGenome>) can begin in parallel with Phase 3, but its MH/crossover operators depend on fugue F2/F3.3. Work breakdown — fugue-evo (all land in
fugue-evo 0.2.0, pre-1.0 breaking-minor)The DELETE / REWRITE / KEEP disposition for
src/fugue_integration/(renamedinference/):Priorenumevolution_model.rs:59-74log_prior_density:182-200prior_coordinate_model:216-239GenomePrior::modellog_boltzmann_target/log_weight/coords:203-209,168-170,173-175total_log_weight()EvolutionModel<G,F>+ builders:81-158EvolutionModel<P,F>sample_prior:242-252run(PriorHandler, prior.model())returnsGto_weighted_trace:261-266coords_of:270-280TraceGenomeEvolutionChainConfig:284-326DiminishingAdaptation+SiteProposalEvolutionStep<G,F>:337-420fugue::inference::mhParticle<G>:424-448Particle+ decode-replayEvolutionarySMC<G,F>:465-800linear_beta_schedule:507next_beta)normalize_weights/effective_sample_size/resample:569-630mutation_sweep:633-645rejuvenate_particlescrossover_sweep+swap_coordinates:654-705,805-834CrossoverKernelweighted_mean/weighted_variance/best_particle:758-799,749-755TraceScoringHandlereffect_handlers.rs:52-134ScoreGivenTraceRecordingHandler<H>:146-213MutationHook/…/Composed*):216-598classic::observeor DELETEhooked_mutate_trace/hooked_crossover_traces:609-752OperationStatistics:756-810BayesianAdaptiveGA+Beta/Gammabayesian_ga.rsclassic::;Beta/Gamma→rand_distr(EV-53)trace_operators(selectors/masks/mutations)trace_operators.rslogp=0.0bodies (EV-54)E0 — Phase 0:
TraceGenometrait split +pplfeature gateRepo: fugue-evo · Size: M · Risk: low · Depends on: nothing (uses fugue 0.2.0)
Files:
src/genome/traits.rs, newsrc/genome/trace_genome.rs, the 5 leaf genome files (real_vector.rs,bit_string.rs,permutation.rs,dynamic_real_vector.rs,tree.rs),composite.rs,src/lib.rs,Cargo.toml,tests/property_tests.rs.EvolutionaryGenomelosesto_trace/from_trace/trace_prefix; thepub use fugue::ChoiceValueattraits.rs:9relocates behind the gate.Leaf genomes: lift each
to_trace/from_trace/trace_prefixbody verbatim into#[cfg(feature="ppl")] impl TraceGenome for _.CompositeGenomeis the one hard case — itsEvolutionaryGenomeimpl keepsA,B: EvolutionaryGenome; its trace impl gains a tighter bound:TreeGenomeType(tree.rs:920-937) and the value sub-traits (RealValuedGenome/BinaryGenome/PermutationGenome) have no trace dependency — untouched.Cargo:
Module tree after split:
genome/(classic) +genome/trace_genome.rs(ppl);algorithms/ operators/ population/ fitness/ hyperparameter/ interactive/ checkpoint/ diagnostics/ termination/ error/(all classic);classic/(Phase 5 home ofBayesianAdaptiveGA);inference/(renamed fromfugue_integration,ppl) withprior.rs,model.rs,mh.rs,smc.rs,grammar.rs,effect_handlers.rs.lib.rs:136becomes#[cfg(feature="ppl")] pub use inference::….--no-default-features(minusppl) compiles with fugue absent: all algorithms/operators/population/fitness/hyperparameter/interactive/checkpoint/diagnostics/termination/error + the core of every genome; the entire wasm crate unchanged (recon: zero fugue imports).Acceptance: whole workspace builds with
--no-default-features --features std,parallel,checkpoint(fugue absent) and with default (fugue present); per-genometest_*_trace_roundtripMOVE under#[cfg(all(test, feature="ppl"))]and call viaTraceGenome;tests/property_tests.rstrace cases (:47-53,101-107,132-140) GATED behindppl, classic props ungated; allalgorithms/operators/…tests UNCHANGED and green.E1 — Phase 1:
GenomePriorreplaces thePriorenumRepo: fugue-evo · Size: M · Risk: low–moderate · Depends on: E0
Load-bearing decision (resolves both specs):
GenomePrior::modelreturnsModel<G>(the decoded genome), notModel<Vec<f64>>+ separatedecode. Rationale: (1) the model's return value is the decode, sofrom_trace/to_tracebecome one generative function and decode-replay (F5) works for free; (2)Vec<f64>cannot express Bool (BitString) or Usize (Permutation) sites — fugue's five leaf types are exactly theChoiceValuevariants; (3) variable structure (DynamicRealVectorlength,TreeGenomeshape) needs the return type to reflect run-dependent structure.Files: new
inference/prior.rs,inference/model.rs; delete/rewrite inevolution_model.rs; keepeffect_handlers.rs.Weight-model fix (recon):
factor(fit)lands intrace.log_factors;adaptive_smctemperslog_likelihood + log_factorsby β, so β applies exactly once — fixing the oldEvolutionarySMCreweighting bydβ · p.fitnesswith rawβ·f.Scoring —
log_prior_densityetc. delete; any point score becomes replay:The uniform-box
−∞-outside behavior is now inherited fromsample(addr, Uniform::new(lo,hi))scored underScoreGivenTrace(out-of-support →-inflogp), not a handmatch.Constructors (
plate!lowers totraverse_vec, O(1)-stack,macros/mod.rs:81; addresses match existingto_traceschemes):Add a small classic
RealVector::from_genes(Vec<f64>) -> RealVector. Variable-length priors (e.g. a geometric-lengthDynamicRealVector) are expressible as genuine generative programs — aCategoricallength site ataddr!("meta","len")then aplate!of that-many coordinates — replacing the old inertmeta/min_length/max_lengthI64 choices with a reallog_priorterm; fugue's MH handles the resulting births/deaths with thedim_termcorrection automatically.Flag: the fitness closure must be
Clone + Send + 'staticto satisfybind'sk: FnOnce + Send + 'static(model.rs:20-131);Fitnessalready boundsSend + Sync + 'static, so requireF: Clone.Acceptance: EV-52
test_to_weighted_trace_carries_fitness_mass(:869) +test_trace_scoring_handler_injects_factor(effect_handlers.rs:824) +e_integration_weighted_trace_is_boltzmann_weight(e_integration_bayesian.rs:77) —total_log_weight() == β·f, mass inlog_factors— KEEP verbatim;test_sample_prior_gaussian_uses_fugue_model(:1025) REPLACE withrun(PriorHandler, GaussianPrior::model())returningRealVector, assert moments; newtest_prior_model_log_prior_matches_analytic(Gaussian:scored.log_prior == Σ Normal::log_prob) as the anchor replacing deletedlog_prior_density.E2 — Phase 2:
EvolutionStep→EvolutionChainoverfugue::inference::mhRepo: fugue-evo · Size: M · Risk: moderate · Depends on: E1, fugue F1
Files:
inference/mh.rs; deleteEvolutionStep/EvolutionChainConfigfromevolution_model.rs.adaptive_single_site_mhpicks the target uniformly overcurrent.choices.keys()and dispatches byChoiceValuetype (mh.rs:837-838), so it moves Bool/Usize/U64/I64 — this is the fix.EvolutionChainConfigcollapses to the two setters.Proposal ↔ site-type map:
gene#iF64 →GaussianWalkProposal(override toReflect{lo,hi}/LogSpace);bit#iBool →FlipProposal;perm#iUsize → prior-resample inline (noProposalStrategy<usize>,mh.rs:516-530);meta#lenUsize → prior-resample triggers trans-dim birth/death withdim_term.Permutation caveat (flag, Phase-1 design task that makes Phase-2 correct): single-site prior-resample of one
perm#ibreaks the permutation invariant unless the prior model enforces it. The correct encoding is a Fisher–Yates / Lehmer-code prior —perm#i ~ Categorical(remaining)— so every trace decodes to a valid permutation and single-site resample stays in support. AuthorPermutationPrior::modelthis way in E1.Acceptance: EV-90
test_mh_respects_bounds(:883, truncated-exponential mean ≈ 1.0746 on [-2,2]) +e_integration_mh_stays_in_bounds(:88) — KEEP assertion, re-drive viaEvolutionChain::run_chain(support from the bounded prior underScoreGivenTrace); new must-add anchorstest_bitstring_chain_moves(≥1 accepted bit-flip over N steps — was 0 under the oldother => other.clone()) andtest_permutation_chain_moves(decoded permutation changes under the Lehmer prior); deleteEvolutionStep::proposeunit tests.E3 — Phase 3:
EvolutionarySMCrebuild + crossover masksRepo: fugue-evo · Size: L · Risk: high · Depends on: E1, fugue F4, F5, F6
Files:
inference/smc.rs; delete the hand-rolled loop inevolution_model.rs.Delete
linear_beta_schedule,normalize_weights,effective_sample_size,resample,mutation_sweep,Particle<G>. Keepweighted_mean/weighted_variance/best_particleas readouts (recomputeGvia decode-replay). Rewritecrossover_sweep+swap_coordinatesas mask closures passed to fugue'sCrossoverKernel(C3 resolution).The mask closures (single-point / two-point / uniform over the genome's fixed address set) are the surviving interface concept of the old
crossover_sweep; they return the address block to swap and satisfy the value-independent, pair-symmetric contract.Acceptance: EV-16
test_smc_matches_gaussian_conjugate_posterior(:924, N(0,4) prior +f=-0.5(x-3)²⇒ mean 2.4, var 0.8) + mirrore_integration_smc_targets_conjugate_posterior(:31, center 2 ⇒ mean 1.6, var 0.8) — KEEP verbatim, re-drive viarun+weighted_mean/weighted_variance, tolerances ±0.15 mean / ±0.2 var. This is the Phase-3 gate and directly exercises the β-single-counting fix.test_smc_basic_normalized(:987) REWRITE → assert fuguenormalize_particlesinvariant onEvolutionResult.particles;test_particle_resampling(:1001) REPLACE → fugueresample_particles(or delete; upstream owns it); newtest_crossover_kernel_preserves_uniform_weights(FG-13).E4 — Phase 4:
TreeGenomeas a probabilistic grammar + subtree surgery + flagshipRepo: fugue-evo · Size: L · Risk: high · Depends on: E3, fugue F2, F3, F6
Files: new
inference/grammar.rs; rewritetrace_operators.rs;TreeGenometrace impl; newexamples/symbolic_regression_inference.rs; extendTerminal/Functiontraits.Tree-path addresses (replace the flat preorder integer index at
tree.rs:703-715,764-860). Each node at pathp=[i0,i1,…]under keyP = "node/" + p.join("/"):ChoiceValueaddr!(P, "is_terminal")Bernoulli(terminal_prob)→ Booladdr!(P, "func")Categorical(uniform over F::functions())→ Usizeaddr!(P, "term_type")Categorical→ Usizeaddr!(P, "const")Normal/Uniform→ F64Children recurse at
P/0, P/1, …up toFunction::arity(:384). Descendants ofpform a contiguous lexicographic key range sharing prefix"node/"+p.join("/")— this is what makes subtree crossover a subtrace swap.Grammar prior (
to_tracebecomes a real PCFG replay, solog_prioraccumulates genuine grammar log-prob;sequence_veckeeps O(1)-stack):Terminal/Functiongain one method each exposing the site distribution (fn site_dist() -> Categorical;Function::arityalready exists).Subtree operators via fugue's reconcile engine:
block_regeneration_mh(fugue F2) with block = every address under"node/"+p. Removed sites are redrawn from the grammar prior; prior terms cancel in the acceptance ratio (dim_term+log_q_fwd/rev, FG-20/21). No bespoke acceptance math.graft_prefix(fugue F3) of parent-B's"node/"+psubtrace into parent-A, then re-score viascore_given_trace_reconciled(mandatory: accumulators aren't address-decomposable). This is the Phase-3 population kernel specialized to trees.trace_operators.rsrewrite:MutationSelector/CrossoverMaskbecome address-set selectors returning the prefixS(theselect_sitesinterface survives;insert_choice(.., 0.0)bodies delete, EV-54).TreeGenomestruct fields unchanged → checkpoint compat holds (only the trace format changes; traces aren't serialized).Flagship —
examples/symbolic_regression_inference.rs(replaces the standalone hand-rolled GP loop,symbolic_regression.rs):GrammarPrior<ArithmeticTerminal, ArithmeticFunction>over expression trees.{(xₖ,yₖ)};f(tree) = −0.5/σ²·Σ(tree.eval(xₖ)−yₖ)²bound viafactor(f(tree))insmc_model, sop(data|tree) ∝ exp(f)and the posteriorπ ∝ p_grammar(tree)·p(data|tree)is a fugue program. Parsimony emerges from the grammar prior (deeper trees cost prior mass), not an ad-hoc penalty.runwithEvoSmcConfig { crossover: Some(subtree-crossover mask), rejuvenation_steps: subtree-regeneration MH }, adaptive β from prior to β=1.best(MAP program),weighted_meanof predictions (posterior predictive),log_evidence(Bayesian model score for comparing function sets),best_fitness(optimizer-mode benchmark vs classic GP).Acceptance: per-tree
test_tree_trace_roundtripREPLACE →test_grammar_trace_has_real_log_prior(log_prior == Σgrammar log-probs, not 0.0); EV-51test_hooked_mutate_does_not_copy_stale_logp(:890) REPLACE →test_subtree_regen_scores_fresh_logp(reconcile re-scores, FG-48); EV-54test_gaussian_mutation_achieves_sigma/test_bounded_mutation_respects_bounds(:450,481) KEEP for classic value operators + ADDtest_subtree_crossover_swaps_prefix_rangeandtest_subtree_regen_prior_cancels_in_ratio; new flagship must-add anchortest_symreg_recovers_known_expression(fitx²+1from noiseless data, MAP tree evaluates correctly). Watch FG-47 — a graft that revisits an address panics.E5 — Phase 5: repositioning, docs, semver
Repo: fugue-evo · Size: M · Risk: low · Depends on: E0–E4 (no code deps)
fugue_integration → inference(all#[cfg(feature="ppl")]). SurvivingBayesianAdaptiveGA+ hooks move toclassic::. Prelude splits:fugue_evo::prelude(classic, fugue-free) andfugue_evo::inference::prelude(gated).BayesianAdaptiveGAreposition (EV-53): it uses conjugateBeta/Gammaas Thompson samplers andEvolutionStep::proposeas a mutation utility (:258-277). Two forced edits: repoint:277 step.propose(parent, rng)to a classic mutation operator orEvolutionChain::step(this also fixes its current real-only limitation); swap fugueBeta/Gamma→rand_distr::{Beta,Gamma}(precedent:hyperparameter/bayesian.rs:23). EV-53 tests (bayesian_ga.rs:353,365,390,399,439) KEEP unchanged (assert conjugate moments / Thompson preference, backend-independent).docs/src/architecture/fugue-integration.md(recon: already stale — documents non-existentConditioningHandler/ResamplingHandler/UniformPrior); move theto_trace/from_tracecontract incustom-genome.md/reference/genomes.mdinto aTraceGenome/pplsection; supersedeSPEC.md; add a new "Evolution as inference" page (GenomePrior, Boltzmann-target-as-program, the symbolic-regression flagship). Tagline → "Two layers: classical evolutionary algorithms (standalone) and evolutionary inference — evolutionary algorithms as probabilistic programs (tempered SMC in trace space, on fugue)."ppl; only--no-default-featuresbuilds lose the inference path. (2)to_trace/from_trace/trace_prefixnow requireuse fugue_evo::genome::TraceGenome; custom genome impls split intoimpl EvolutionaryGenome+impl TraceGenome. (3)ChoiceValuere-export moved behindppl. (4)Prior::Gaussian{..}→GaussianPrior::new(mean,std,dim);Prior::UniformBounds→UniformBoxPrior::new(bounds). (5)EvolutionStep::new(..).run_chain(..)→EvolutionChain::new(EvolutionModel::new(prior, fitness)).run_chain(rng,&init,n,warmup). (6)EvolutionarySMC→EvolutionSMC::run(rng,&model,EvoSmcConfig{..}); readouts move fromParticle<G>fields toEvolutionResult::{weighted_mean,best,log_evidence}. (7)BayesianAdaptiveGA→fugue_evo::classic::BayesianAdaptiveGA.Acceptance:
cargo docclean; migration-guide example snippets compile as doc-tests; wasm crate builds unchanged (E0 already proves this).4. Risk register (cross-repo)
Vec<f64>hot loopsScoreGivenTraceper rejuvenation; crossover re-scores both childrenbest_fitnessbenchmark quantifies the gap). Batch decodes; reuserun's O(1)-stack trampoline (FG-19). Do not storeAonParticle(would forceA: Clone+ clone-on-resample, worse).test_crossover_preserves_uniform_weights(bit-identicallog_weight),test_crossover_evidence_noncorruption(vsNoKernel), and the conjugate product-target validation are gating. v1 restricts to fixed-structure genomes whereScoreGivenTraceis exact.smc_modelinjectsfactor(f)(notβ·f); β comes only from tempering. EV-16 is the gate. Code review the two builders (target_modeluses β;smc_modeldoes not).PermutationPrior::model(perm#i ~ Categorical(remaining)), authored in E1;test_permutation_chain_movesasserts valid decoded permutations.score_given_trace_reconciled+dim_term(F2); Geweke reversibility test; FG-47 vigilance (dup-address graft panics — use reconciling re-score to detect).test_subtree_regen_prior_cancels_in_ratio.test_crossover_evidence_noncorruption.CHECKPOINT_VERSION=1preserved through Phase 0–3 (trait split + inference rewrite touch no genome fields). Phase 4 keepsTreeGenomefields unchanged (grammar reads existingTreeNodestructure) → no bump; only a field change would force version 2 + shim.TraceGenome. Compiles unchanged withpplon or off.getrandomjs feature and[profile.release]untouched.0.2.0 → 0.2.1per the crate's stated pre-1.0 posture. NoParticlefield change (decode-replay avoids the0.3.0bump).**(breaking)**in CHANGELOG (Keep-a-Changelog, continueEV-xxafter EV-106). Real-world blast radius minimal:fugue_integration's only external consumers are in-repo (examples/bayesian_evolution.rs,tests/e_integration_bayesian.rs). Ship the §3.B.E5 migration guide.Fitness: Clonebound propagationbind's continuation requiresFnOnce + Send + 'static;Fitnessalready boundsSend+Sync+'static, so only addClone. Audit allFitnessimpls compile.5. Validation strategy
5.1 The analytic-test ladder (fugue's known-answer convention throughout)
Rung 0 — fugue unit invariants (0.2.1):
test_smc_rejuvenation_moves_bitstring(population not frozen; marginal bit-means match analytic Bernoulli posterior).test_extract_prefix_boundary,test_graft_round_trip,test_graft_rescore_equality,test_extract_zeroes_accumulators.test_decode_fidelity,test_decode_weighted_mean.Rung 1 — conjugate posteriors (the core anchors):
test_block_regen_beta_bernoulli(closed-form Beta posterior, 2 MC-SE,validation.rs:144).test_crossover_product_invariance(two independent conjugate-Normal, product target,validate_against_analytical_posterior,validation.rs:173).run— the single most important carryover, proving the rebuild targets the same Boltzmann posterior and the β fix.Rung 2 — mechanism invariants:
total_log_weight() == β·f, mass inlog_factors(KEEP verbatim).EvolutionChain(support enforced by bounded prior underScoreGivenTrace).test_crossover_preserves_uniform_weights(W),test_crossover_evidence_noncorruption(E).Rung 3 — the dead-chain fix (new, no prior test existed):
test_bitstring_chain_moves,test_permutation_chain_moves(E2) — prove the verifiedEvolutionStep::proposeF64-only no-op is fixed for non-real genomes.Rung 4 — structural correctness:
test_block_regen_transdimensional(Geweke reversibility),test_block_vs_sequential_single_site(KS).test_grammar_trace_has_real_log_prior,test_subtree_regen_prior_cancels_in_ratio,test_subtree_crossover_swaps_prefix_range.Cross-cutting guards to keep green every phase: EV-51 (
RecordingHandlerbookkeeping;hooked_mutatefresh-logp →test_subtree_regen_scores_fresh_logp), EV-53 (Beta/Gamma conjugate moments, backend-independent), EV-54 (classic value-mutation sigma/bounds), plus fugue's own FG regression suite (FG-03/13/43/58 for SMC; FG-20/21/47/48 for MH/reconcile).5.2 Flagship end-to-end proof — symbolic regression as exact Bayesian inference
The demo (
examples/symbolic_regression_inference.rs, E4) is the end-to-end argument that the conception holds:y = x² + 1, noiseless dataset{(xₖ, yₖ)}on a grid. PriorGrammarPrior<ArithmeticTerminal, ArithmeticFunction>over expression trees (PCFG with real grammar log-probs). Likelihoodf(tree) = −0.5/σ²·Σ(tree.eval(xₖ)−yₖ)²bound viafactor(f(tree)).π(tree) ∝ p_grammar(tree)·exp(f(tree))is a fugue program — nothing evolutionary-specific in the inference engine; fugue only executes it. Selection = SMC resampling; mutation = subtree block-regeneration MH (fugue F2); crossover = subtree subtrace-swap population kernel (fugue F3/F4).test_symreg_recovers_known_expression: the MAP program (EvolutionResult::best) evaluates tox²+1on held-out points (parsimony from the grammar prior, no ad-hoc penalty). Reportlog_evidenceas the Bayesian model score andbest_fitnessas the optimizer-mode benchmark against the classic GP loop.6. NOT-doing list
Stays in fugue-evo, fugue-free (classic layer):
fuguedependency (Phase-5 "classic module").EvolutionaryGenome/TraceGenome, all genome types,to_trace/from_trace. The genome↔trace mapping (addr!("gene",i),addr!("bit",i), tree-path naming,CompositeGenomenamespacing) is a domain encode/decode convention.factor(β·f)is fugue's hook; theFitnessabstraction, theEvolutionModelbundle, and the "fitness as likelihood" convention (to_weighted_trace, EV-52) are application semantics.CrossoverKernel/block_regeneration_mhtake an address set/closure.BayesianAdaptiveGA,Beta/Gammaas plainrand_distrsamplers) — an operator-selection heuristic, not inference; lives inclassic::.Gon particles — resolved via decode-replay + a fugue-evo-side cache.serde-of-G+ ChaCha snapshots; no trace serialized.fugue refuses to absorb (stays generic, keyed by address + value type only):
Fitnesstrait — fugue providesfactor, not a fitness abstraction.AonParticle— decode-replay (F5) instead, keepingParticle { trace, weight, log_weight }stable and additive.CrossoverKernel— restricted to fixed-structure genomes; the trans-dimensional case is documented as a custom-kernel exercise usingscore_given_trace_reconciled, deferred.The dividing line: fugue owns trace-space operations invariant to what the trace means (population kernels, block regeneration, subtrace surgery, decode-replay, tempered typed MH); fugue-evo owns what the trace means (genomes, fitness, operators, grammars, algorithms).