Skip to content

EA-as-PPL: two-layer refactor — standalone classic EC + fugue-native inference (fugue-evo 0.2.0) #18

Description

@alexnodeland

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 DELETEGenomePrior::model 1 S
log_boltzmann_target/log_weight/coords :203-209,168-170,173-175 REWRITEtotal_log_weight() 1 S
EvolutionModel<G,F> + builders :81-158 REWRITEEvolutionModel<P,F> 1 M
sample_prior :242-252 REWRITErun(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 DELETEfugue::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 DELETErejuvenate_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/Gammarand_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.

Scoringlog_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_factorsKEEP 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: EvolutionStepEvolutionChain 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 priorperm#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 REPLACEtest_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) REPLACEtest_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/Gammarand_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::UniformBoundsUniformBoxPrior::new(bounds). (5) EvolutionStep::new(..).run_chain(..)EvolutionChain::new(EvolutionModel::new(prior, fitness)).run_chain(rng,&init,n,warmup). (6) EvolutionarySMCEvolutionSMC::run(rng,&model,EvoSmcConfig{..}); readouts move from Particle<G> fields to EvolutionResult::{weighted_mean,best,log_evidence}. (7) BayesianAdaptiveGAfugue_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:

  1. 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)).
  2. 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).
  3. 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.
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions