Skip to main content

symbios_shape/
genetics.rs

1//! Genetic evolution wrapper for CGA Shape Grammar interpreters.
2//!
3//! Provides [`ShapeGenotype`], a [`symbios_genetics::Genotype`]-compatible
4//! wrapper around the grammar rule table. Plug it directly into any
5//! `symbios-genetics` algorithm (SimpleGA, NSGA-II, MAP-Elites) to evolve
6//! procedural building grammars interactively.
7//!
8//! # Example
9//!
10//! ```rust
11//! use symbios_shape::{Interpreter, Scope, Vec3, Quat};
12//! use symbios_shape::grammar::parse_ops;
13//! use symbios_shape::genetics::ShapeGenotype;
14//! use symbios_genetics::Genotype;
15//! use rand::SeedableRng;
16//! use rand_pcg::Pcg64;
17//!
18//! let mut interp = Interpreter::new();
19//! interp.add_rule("Lot", parse_ops("Extrude(10) Split(Y) { 3: Floor | ~1: Roof }").unwrap());
20//! interp.add_rule("Floor", parse_ops(r#"I("Floor")"#).unwrap());
21//! interp.add_rule("Roof",  parse_ops(r#"Taper(0.8) I("Roof")"#).unwrap());
22//!
23//! let mut dna = ShapeGenotype::from_interpreter(&interp);
24//! let mut rng = Pcg64::seed_from_u64(42);
25//! dna.mutate(&mut rng, 0.3);
26//!
27//! let evolved = dna.to_interpreter();
28//! let footprint = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 0.0, 10.0));
29//! let _model = evolved.derive(footprint, "Lot").unwrap();
30//! ```
31
32use std::collections::HashMap;
33
34use rand::Rng;
35use serde::{Deserialize, Serialize};
36use symbios_genetics::Genotype;
37
38use crate::expr::Expr;
39use crate::interpreter::{Interpreter, RuleDef};
40use crate::ops::{RuleVariant, ShapeOp, SplitEntry, SplitSize, VariantSelector};
41
42// ── ShapeGenotype ─────────────────────────────────────────────────────────────
43
44/// Genetic encoding of a CGA shape grammar.
45///
46/// Wraps the rule table of an [`Interpreter`] so that the grammar can be
47/// evolved by `symbios-genetics` algorithms.  Parametric floats are mutated
48/// with Gaussian jitter; crossover uses homologous BLX-α blending on rules
49/// that share both name and op-sequence topology, or uniform crossover when
50/// topologies differ.
51#[derive(Clone, Debug, Serialize, Deserialize)]
52pub struct ShapeGenotype {
53    /// Grammar rules: rule name → definition (declared parameters + variants).
54    /// Parameter lists ride along unmutated so parameterized grammars
55    /// round-trip through evolution intact.
56    pub rules: HashMap<String, RuleDef>,
57}
58
59impl ShapeGenotype {
60    /// Snapshot the rule table from a live interpreter.
61    pub fn from_interpreter(interp: &Interpreter) -> Self {
62        Self {
63            rules: interp.rules().clone(),
64        }
65    }
66
67    /// Build a fresh [`Interpreter`] from this genotype.
68    ///
69    /// Does **not** copy `seed`, `max_depth`, `max_terminals`, or host
70    /// attributes — set those on the returned interpreter if your grammar
71    /// requires non-default values.
72    pub fn to_interpreter(&self) -> Interpreter {
73        let mut interp = Interpreter::new();
74        for (name, def) in &self.rules {
75            // Validation cannot fail here: the def came from a live
76            // interpreter (or a snapshot of one).
77            let _ =
78                interp.add_rule_variants(name.clone(), def.params.clone(), def.variants.clone());
79        }
80        interp
81    }
82}
83
84// ── Genotype impl ─────────────────────────────────────────────────────────────
85
86impl Genotype for ShapeGenotype {
87    /// Perturb parametric floats throughout the grammar.
88    ///
89    /// Each mutable float is independently tested against `rate`.  When
90    /// selected, Gaussian noise (Box-Muller) is applied and the result is
91    /// clamped to keep the grammar structurally valid:
92    ///
93    /// | Op | Parameter | σ | Clamp |
94    /// |---|---|---|---|
95    /// | `Extrude(h)` | h | 0.5 | > 0.1 |
96    /// | `Taper(t)` | t | 0.1 | [0, 1] |
97    /// | `Scale(v)` | each component | 0.2 | > 0.1 |
98    /// | `Translate(v)` | each component | 0.5 | none |
99    /// | `Split` slot sizes | size value | 0.3 | > 0.1 (or [0.01, 1] for Relative) |
100    /// | `Repeat` tile_size | tile_size | 0.3 | > 0.1 |
101    /// | `Roof` angle | angle (°) | 5.0 | [1, 89] |
102    /// | `Roof` overhang | overhang | 0.2 | [0, 2] |
103    ///
104    /// After per-slot jitter, each `Split` is repaired so that its absolute and
105    /// relative slot sums do not exceed their pre-mutation totals — this prevents
106    /// independent jitter from producing `SplitOverflow` errors at interpret time
107    /// (issue #27).
108    fn mutate<R: Rng>(&mut self, rng: &mut R, rate: f32) {
109        // Iterate by sorted key so the per-op RNG draws are deterministic for
110        // a given seed — `HashMap`'s default value iteration order is not.
111        let mut keys: Vec<&String> = self.rules.keys().collect();
112        keys.sort();
113        let keys: Vec<String> = keys.into_iter().cloned().collect();
114        for key in keys {
115            if let Some(def) = self.rules.get_mut(&key) {
116                for variant in def.variants.iter_mut() {
117                    for op in variant.ops.iter_mut() {
118                        mutate_op(op, rng, rate);
119                    }
120                }
121            }
122        }
123    }
124
125    /// Homologous BLX-α crossover (α = 0.5).
126    ///
127    /// For each rule name shared by both parents:
128    /// - If both parents have the **same number of variants** and each
129    ///   variant pair has the **same op-sequence topology** (same variant
130    ///   discriminants in the same order), every float parameter is blended
131    ///   using BLX-α, producing offspring that explore slightly beyond the
132    ///   parental range.
133    /// - If the variant counts or topologies differ, the whole rule is
134    ///   inherited uniformly at random from one parent (50 / 50).
135    ///
136    /// Rules present in only one parent are passed through to the child
137    /// unchanged, so the child always has a complete, runnable grammar.
138    fn crossover<R: Rng>(&self, other: &Self, rng: &mut R) -> Self {
139        let mut child_rules = self.rules.clone();
140
141        for (name, self_def) in &self.rules {
142            if let Some(other_def) = other.rules.get(name) {
143                if self_def.variants.len() == other_def.variants.len() {
144                    let blended: Vec<RuleVariant> = self_def
145                        .variants
146                        .iter()
147                        .zip(other_def.variants.iter())
148                        .map(|(sv, ov)| crossover_variant(sv, ov, rng))
149                        .collect();
150                    child_rules.insert(
151                        name.clone(),
152                        RuleDef {
153                            params: self_def.params.clone(),
154                            variants: blended,
155                        },
156                    );
157                } else if rng.random::<f32>() < 0.5 {
158                    child_rules.insert(name.clone(), other_def.clone());
159                }
160                // else: keep self's rule (already cloned into child_rules)
161            }
162        }
163
164        // Rules present only in `other` are added to the child.
165        for (name, def) in &other.rules {
166            if !child_rules.contains_key(name) {
167                child_rules.insert(name.clone(), def.clone());
168            }
169        }
170
171        ShapeGenotype { rules: child_rules }
172    }
173}
174
175// ── Gaussian jitter ───────────────────────────────────────────────────────────
176
177/// Returns `value + N(0, sigma)` with probability `rate`, else `value`.
178///
179/// Uses the Box-Muller transform for Gaussian sampling from two uniform draws.
180fn jitter<R: Rng>(rng: &mut R, rate: f32, value: f64, sigma: f64) -> f64 {
181    if rng.random::<f32>() < rate {
182        let u1: f64 = rng.random::<f64>().max(1e-15); // avoid log(0)
183        let u2: f64 = rng.random::<f64>();
184        let gauss = (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
185        value + sigma * gauss
186    } else {
187        value
188    }
189}
190
191// ── Per-op mutation ───────────────────────────────────────────────────────────
192
193/// Jitters an argument expression.
194///
195/// A bare literal keeps the pre-0.3 semantics exactly: jitter, then apply the
196/// op's validity clamp. A compound expression has each literal leaf jittered
197/// with the same sigma and **no** clamp — its final value depends on the
198/// evaluation context, so validity is enforced at derivation time instead
199/// (an invalid draw surfaces as a derive error / poor fitness).
200fn jitter_expr<R: Rng>(rng: &mut R, rate: f32, e: &mut Expr, sigma: f64, post: fn(f64) -> f64) {
201    if let Expr::Lit(v) = e {
202        *v = post(jitter(rng, rate, *v, sigma));
203    } else {
204        e.visit_literals_mut(&mut |v| *v = jitter(rng, rate, *v, sigma));
205    }
206}
207
208fn mutate_op<R: Rng>(op: &mut ShapeOp, rng: &mut R, rate: f32) {
209    match op {
210        ShapeOp::Extrude(h) => {
211            jitter_expr(rng, rate, h, 0.5, |v| v.max(0.1));
212        }
213        ShapeOp::Taper(t) => {
214            jitter_expr(rng, rate, t, 0.1, |v| v.clamp(0.0, 1.0));
215        }
216        ShapeOp::Scale(v) => {
217            for c in v.iter_mut() {
218                jitter_expr(rng, rate, c, 0.2, |x| x.max(0.1));
219            }
220        }
221        ShapeOp::Translate(v) => {
222            for c in v.iter_mut() {
223                jitter_expr(rng, rate, c, 0.5, |x| x);
224            }
225        }
226        ShapeOp::Split { entries, .. } => {
227            // Snapshot pre-mutation sums so we can prevent absolute / relative
228            // sums from growing past their original totals. Without this guard
229            // independent per-slot Gaussian jitter can push Σ(absolute) past
230            // the scope dimension and trip `SplitOverflow` at interpret time.
231            // Only bare-literal single slots participate — group slots and
232            // expression-valued sizes cannot be summed without a context.
233            let original_abs_sum =
234                sum_split_entries(entries, |s| matches!(s, SplitSize::Absolute(_)));
235            let original_rel_sum =
236                sum_split_entries(entries, |s| matches!(s, SplitSize::Relative(_)));
237            for entry in entries.iter_mut() {
238                match entry {
239                    SplitEntry::Slot(slot) => mutate_split_size(&mut slot.size, rng, rate),
240                    SplitEntry::Group(slots) => {
241                        for slot in slots.iter_mut() {
242                            mutate_split_size(&mut slot.size, rng, rate);
243                        }
244                    }
245                }
246            }
247            repair_split_entry_sums(entries, original_abs_sum, original_rel_sum);
248        }
249        ShapeOp::SplitArea { slots, .. } => {
250            for slot in slots.iter_mut() {
251                mutate_split_size(&mut slot.size, rng, rate);
252            }
253        }
254        ShapeOp::Fit { candidates, .. } => {
255            for c in candidates.iter_mut() {
256                jitter_expr(rng, rate, &mut c.min_size, 0.2, |v| v.max(0.0));
257            }
258        }
259        ShapeOp::Size(v) => {
260            for c in v.iter_mut() {
261                jitter_expr(rng, rate, c, 0.2, |x| x.max(0.0));
262            }
263        }
264        ShapeOp::ShapeL { front, side, .. } => {
265            jitter_expr(rng, rate, front, 0.3, |v| v.max(0.1));
266            jitter_expr(rng, rate, side, 0.3, |v| v.max(0.1));
267        }
268        ShapeOp::ShapeU {
269            front, left, right, ..
270        } => {
271            jitter_expr(rng, rate, front, 0.3, |v| v.max(0.1));
272            jitter_expr(rng, rate, left, 0.3, |v| v.max(0.1));
273            jitter_expr(rng, rate, right, 0.3, |v| v.max(0.1));
274        }
275        ShapeOp::Scatter { count, .. } => {
276            jitter_expr(rng, rate, count, 1.0, |v| v.max(0.0));
277        }
278        ShapeOp::Repeat { tile_sizes, .. } => {
279            for ts in tile_sizes.iter_mut() {
280                jitter_expr(rng, rate, ts, 0.3, |v| v.max(0.1));
281            }
282        }
283        ShapeOp::Roof { spec, .. } => {
284            jitter_expr(rng, rate, &mut spec.pitch, 5.0, |v| v.clamp(1.0, 89.0));
285            jitter_expr(rng, rate, &mut spec.overhang, 0.2, |v| v.clamp(0.0, 2.0));
286            if let Some(h) = &mut spec.height {
287                jitter_expr(rng, rate, h, 0.5, |v| v.max(0.1));
288            }
289        }
290        ShapeOp::Polygon(verts) => {
291            for v in verts.iter_mut() {
292                v.x = jitter(rng, rate, v.x, 0.2);
293                v.y = jitter(rng, rate, v.y, 0.2);
294            }
295        }
296        // Non-parametric ops have no float to jitter. Rotate is deliberately
297        // excluded: independent per-component quaternion jitter produces
298        // wild, rarely-useful orientations.
299        ShapeOp::Rotate(_)
300        | ShapeOp::Comp(_)
301        | ShapeOp::Offset { .. }
302        | ShapeOp::I(_)
303        | ShapeOp::Mat(_)
304        | ShapeOp::Rule(_)
305        | ShapeOp::Align { .. }
306        | ShapeOp::Attach { .. }
307        | ShapeOp::RegSnap(_)
308        | ShapeOp::IfClear { .. }
309        | ShapeOp::IfOccluded { .. }
310        | ShapeOp::IfInside { .. }
311        | ShapeOp::IfTouches { .. }
312        | ShapeOp::Label(_)
313        | ShapeOp::Pick { .. }
314        | ShapeOp::Center { .. }
315        | ShapeOp::Mirror => {}
316    }
317}
318
319fn mutate_split_size<R: Rng>(size: &mut SplitSize, rng: &mut R, rate: f32) {
320    match size {
321        SplitSize::Absolute(e) => jitter_expr(rng, rate, e, 0.3, |v| v.max(0.1)),
322        SplitSize::Relative(e) => jitter_expr(rng, rate, e, 0.05, |v| v.clamp(0.01, 1.0)),
323        SplitSize::Floating(e) => jitter_expr(rng, rate, e, 0.3, |v| v.max(0.1)),
324    }
325}
326
327/// Sums **bare-literal** size values of single (non-group) slots whose
328/// `SplitSize` matches `kind`. Group and expression-valued sizes are
329/// excluded (no evaluation context here).
330fn sum_split_entries<F>(entries: &[SplitEntry], kind: F) -> f64
331where
332    F: Fn(&SplitSize) -> bool,
333{
334    entries
335        .iter()
336        .filter_map(SplitEntry::as_slot)
337        .filter(|s| kind(&s.size))
338        .filter_map(|s| s.size.expr().as_lit())
339        .sum()
340}
341
342/// Prevents Split absolute / relative slot sums from growing past their
343/// pre-mutation totals. Each affected **literal** single slot is scaled down
344/// proportionally, preserving the relative weights chosen by the mutation.
345fn repair_split_entry_sums(
346    entries: &mut [SplitEntry],
347    original_abs_sum: f64,
348    original_rel_sum: f64,
349) {
350    let new_abs_sum = sum_split_entries(entries, |s| matches!(s, SplitSize::Absolute(_)));
351    if original_abs_sum > 1e-9 && new_abs_sum > original_abs_sum {
352        let scale = original_abs_sum / new_abs_sum;
353        for entry in entries.iter_mut() {
354            if let SplitEntry::Slot(slot) = entry
355                && let SplitSize::Absolute(Expr::Lit(v)) = &mut slot.size
356            {
357                *v = (*v * scale).max(0.1);
358            }
359        }
360    }
361    let new_rel_sum = sum_split_entries(entries, |s| matches!(s, SplitSize::Relative(_)));
362    if original_rel_sum > 1e-9 && new_rel_sum > original_rel_sum {
363        let scale = original_rel_sum / new_rel_sum;
364        for entry in entries.iter_mut() {
365            if let SplitEntry::Slot(slot) = entry
366                && let SplitSize::Relative(Expr::Lit(v)) = &mut slot.size
367            {
368                *v = (*v * scale).clamp(0.01, 1.0);
369            }
370        }
371    }
372}
373
374// ── Per-variant crossover ─────────────────────────────────────────────────────
375
376fn crossover_variant<R: Rng>(a: &RuleVariant, b: &RuleVariant, rng: &mut R) -> RuleVariant {
377    if same_structure(&a.ops, &b.ops) {
378        let ops = a
379            .ops
380            .iter()
381            .zip(b.ops.iter())
382            .map(|(ao, bo)| blend_op(ao, bo, rng))
383            .collect();
384        // Weights blend; guard conditions are logic, not aesthetics — the
385        // child keeps parent A's selector verbatim.
386        let selector = match (&a.selector, &b.selector) {
387            (VariantSelector::Weight(wa), VariantSelector::Weight(wb)) => {
388                VariantSelector::Weight(blx(*wa, *wb, 0.5, rng).max(0.0))
389            }
390            _ => a.selector.clone(),
391        };
392        RuleVariant { selector, ops }
393    } else {
394        // Topologies differ — uniform crossover: pick one parent whole.
395        if rng.random::<f32>() < 0.5 {
396            a.clone()
397        } else {
398            b.clone()
399        }
400    }
401}
402
403/// Returns `true` when `a` and `b` have identical ShapeOp variant sequences.
404fn same_structure(a: &[ShapeOp], b: &[ShapeOp]) -> bool {
405    a.len() == b.len() && a.iter().zip(b.iter()).all(|(ao, bo)| same_op_kind(ao, bo))
406}
407
408fn same_op_kind(a: &ShapeOp, b: &ShapeOp) -> bool {
409    use ShapeOp::*;
410    matches!(
411        (a, b),
412        (Extrude(_), Extrude(_))
413            | (Taper(_), Taper(_))
414            | (Rotate(_), Rotate(_))
415            | (Translate(_), Translate(_))
416            | (Scale(_), Scale(_))
417            | (Split { .. }, Split { .. })
418            | (SplitArea { .. }, SplitArea { .. })
419            | (Fit { .. }, Fit { .. })
420            | (Size(_), Size(_))
421            | (Center { .. }, Center { .. })
422            | (Mirror, Mirror)
423            | (Label(_), Label(_))
424            | (IfInside { .. }, IfInside { .. })
425            | (IfTouches { .. }, IfTouches { .. })
426            | (Scatter { .. }, Scatter { .. })
427            | (Pick { .. }, Pick { .. })
428            | (ShapeL { .. }, ShapeL { .. })
429            | (ShapeU { .. }, ShapeU { .. })
430            | (Repeat { .. }, Repeat { .. })
431            | (Comp(_), Comp(_))
432            | (I(_), I(_))
433            | (Mat(_), Mat(_))
434            | (Rule(_), Rule(_))
435            | (Align { .. }, Align { .. })
436            | (Offset { .. }, Offset { .. })
437            | (Roof { .. }, Roof { .. })
438            | (Attach { .. }, Attach { .. })
439            | (Polygon(_), Polygon(_))
440    )
441}
442
443// ── BLX-α blend ──────────────────────────────────────────────────────────────
444
445/// BLX-α blend: samples uniformly from `[min − α·d, max + α·d]` where `d = max − min`.
446///
447/// With α = 0.0 this reduces to uniform crossover in the parental range.
448/// With α = 0.5 (the default used here) it allows moderate exploration beyond parents.
449fn blx<R: Rng>(a: f64, b: f64, alpha: f64, rng: &mut R) -> f64 {
450    let lo = a.min(b);
451    let hi = a.max(b);
452    let d = (hi - lo) * alpha;
453    let lo_ext = lo - d;
454    let hi_ext = hi + d;
455    if hi_ext <= lo_ext {
456        (a + b) / 2.0
457    } else {
458        rng.random::<f64>() * (hi_ext - lo_ext) + lo_ext
459    }
460}
461
462/// Blends two argument expressions: literal-vs-literal pairs BLX-blend (with
463/// the op's validity clamp); any other pairing keeps parent A's expression.
464fn blend_expr<R: Rng>(a: &Expr, b: &Expr, rng: &mut R, post: fn(f64) -> f64) -> Expr {
465    match (a.as_lit(), b.as_lit()) {
466        (Some(x), Some(y)) => Expr::Lit(post(blx(x, y, 0.5, rng))),
467        _ => a.clone(),
468    }
469}
470
471fn blend_expr3<R: Rng>(
472    a: &[Expr; 3],
473    b: &[Expr; 3],
474    rng: &mut R,
475    post: fn(f64) -> f64,
476) -> [Expr; 3] {
477    [
478        blend_expr(&a[0], &b[0], rng, post),
479        blend_expr(&a[1], &b[1], rng, post),
480        blend_expr(&a[2], &b[2], rng, post),
481    ]
482}
483
484fn blend_op<R: Rng>(a: &ShapeOp, b: &ShapeOp, rng: &mut R) -> ShapeOp {
485    match (a, b) {
486        (ShapeOp::Extrude(ha), ShapeOp::Extrude(hb)) => {
487            ShapeOp::Extrude(blend_expr(ha, hb, rng, |v| v.max(0.1)))
488        }
489        (ShapeOp::Taper(ta), ShapeOp::Taper(tb)) => {
490            ShapeOp::Taper(blend_expr(ta, tb, rng, |v| v.clamp(0.0, 1.0)))
491        }
492        (ShapeOp::Scale(va), ShapeOp::Scale(vb)) => {
493            ShapeOp::Scale(blend_expr3(va, vb, rng, |v| v.max(0.1)))
494        }
495        (ShapeOp::Translate(va), ShapeOp::Translate(vb)) => {
496            ShapeOp::Translate(blend_expr3(va, vb, rng, |v| v))
497        }
498        (
499            ShapeOp::Split {
500                axis,
501                entries: entries_a,
502                snap,
503            },
504            ShapeOp::Split {
505                entries: entries_b, ..
506            },
507        ) => {
508            // Blend sizes pairwise where the entry structure matches; keep
509            // rules, axis, snap and any group structure from parent A.
510            let entries = if entries_a.len() == entries_b.len() {
511                entries_a
512                    .iter()
513                    .zip(entries_b.iter())
514                    .map(|(ea, eb)| match (ea, eb) {
515                        (SplitEntry::Slot(sa), SplitEntry::Slot(sb)) => {
516                            SplitEntry::Slot(crate::ops::SplitSlot {
517                                size: blend_split_size(&sa.size, &sb.size, rng),
518                                rule: sa.rule.clone(),
519                            })
520                        }
521                        (SplitEntry::Group(ga), SplitEntry::Group(gb)) if ga.len() == gb.len() => {
522                            SplitEntry::Group(
523                                ga.iter()
524                                    .zip(gb.iter())
525                                    .map(|(sa, sb)| crate::ops::SplitSlot {
526                                        size: blend_split_size(&sa.size, &sb.size, rng),
527                                        rule: sa.rule.clone(),
528                                    })
529                                    .collect(),
530                            )
531                        }
532                        _ => ea.clone(),
533                    })
534                    .collect()
535            } else {
536                entries_a.clone()
537            };
538            ShapeOp::Split {
539                axis: *axis,
540                entries,
541                snap: snap.clone(),
542            }
543        }
544        (
545            ShapeOp::Repeat {
546                axis,
547                tile_sizes: tsa,
548                rule,
549            },
550            ShapeOp::Repeat {
551                tile_sizes: tsb, ..
552            },
553        ) => {
554            let blended: Vec<Expr> = if tsa.len() == tsb.len() {
555                tsa.iter()
556                    .zip(tsb.iter())
557                    .map(|(a, b)| blend_expr(a, b, rng, |v| v.max(0.1)))
558                    .collect()
559            } else {
560                tsa.clone()
561            };
562            ShapeOp::Repeat {
563                axis: *axis,
564                tile_sizes: blended,
565                rule: rule.clone(),
566            }
567        }
568        (ShapeOp::Roof { spec: sa, cases }, ShapeOp::Roof { spec: sb, .. }) => {
569            let mut spec = sa.clone();
570            spec.pitch = blend_expr(&sa.pitch, &sb.pitch, rng, |v| v.clamp(1.0, 89.0));
571            spec.overhang = blend_expr(&sa.overhang, &sb.overhang, rng, |v| v.clamp(0.0, 2.0));
572            ShapeOp::Roof {
573                spec,
574                cases: cases.clone(),
575            }
576        }
577        // Non-parametric or unblendable: use parent A unchanged.
578        _ => a.clone(),
579    }
580}
581
582fn blend_split_size<R: Rng>(a: &SplitSize, b: &SplitSize, rng: &mut R) -> SplitSize {
583    match (a, b) {
584        (SplitSize::Absolute(va), SplitSize::Absolute(vb)) => {
585            SplitSize::Absolute(blend_expr(va, vb, rng, |v| v.max(0.1)))
586        }
587        (SplitSize::Relative(va), SplitSize::Relative(vb)) => {
588            SplitSize::Relative(blend_expr(va, vb, rng, |v| v.clamp(0.01, 1.0)))
589        }
590        (SplitSize::Floating(va), SplitSize::Floating(vb)) => {
591            SplitSize::Floating(blend_expr(va, vb, rng, |v| v.max(0.1)))
592        }
593        // Mixed SplitSize kinds: keep parent A's.
594        _ => a.clone(),
595    }
596}
597
598// ── Tests ─────────────────────────────────────────────────────────────────────
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use crate::grammar::parse_ops;
604    use crate::scope::Vec3;
605    use rand::SeedableRng;
606    use rand_pcg::Pcg64;
607
608    fn build_interp() -> Interpreter {
609        let mut interp = Interpreter::new();
610        interp.add_rule(
611            "Lot",
612            parse_ops("Extrude(10) Split(Y) { 3: Floor | ~1: Top }").unwrap(),
613        );
614        interp.add_rule("Floor", parse_ops(r#"Taper(0.0) I("Floor")"#).unwrap());
615        interp.add_rule("Top", parse_ops(r#"Taper(0.8) I("Roof")"#).unwrap());
616        interp
617    }
618
619    #[test]
620    fn test_round_trip() {
621        let interp = build_interp();
622        let dna = ShapeGenotype::from_interpreter(&interp);
623        let interp2 = dna.to_interpreter();
624        // Both should derive the same shape model.
625        let footprint = crate::scope::Scope::new(
626            Vec3::ZERO,
627            crate::scope::Quat::IDENTITY,
628            Vec3::new(10.0, 0.0, 10.0),
629        );
630        let m1 = interp.derive(footprint, "Lot").unwrap();
631        let m2 = interp2.derive(footprint, "Lot").unwrap();
632        assert_eq!(m1.len(), m2.len());
633        assert_eq!(m1.terminals[0].mesh_id, m2.terminals[0].mesh_id);
634    }
635
636    #[test]
637    fn test_mutate_preserves_validity() {
638        let interp = build_interp();
639        let mut dna = ShapeGenotype::from_interpreter(&interp);
640        let mut rng = Pcg64::seed_from_u64(7);
641        // High rate to exercise most code paths.
642        dna.mutate(&mut rng, 1.0);
643        let interp2 = dna.to_interpreter();
644        let footprint = crate::scope::Scope::new(
645            Vec3::ZERO,
646            crate::scope::Quat::IDENTITY,
647            Vec3::new(10.0, 0.0, 10.0),
648        );
649        // Should still derive without error.
650        interp2.derive(footprint, "Lot").unwrap();
651    }
652
653    #[test]
654    fn test_crossover_produces_valid_grammar() {
655        let interp_a = build_interp();
656        let mut interp_b = build_interp();
657        // Give parent B different parameters.
658        interp_b.add_rule(
659            "Lot",
660            parse_ops("Extrude(20) Split(Y) { 5: Floor | ~2: Top }").unwrap(),
661        );
662
663        let dna_a = ShapeGenotype::from_interpreter(&interp_a);
664        let dna_b = ShapeGenotype::from_interpreter(&interp_b);
665        let mut rng = Pcg64::seed_from_u64(99);
666        let child = dna_a.crossover(&dna_b, &mut rng);
667        let interp_child = child.to_interpreter();
668
669        let footprint = crate::scope::Scope::new(
670            Vec3::ZERO,
671            crate::scope::Quat::IDENTITY,
672            Vec3::new(10.0, 0.0, 10.0),
673        );
674        interp_child.derive(footprint, "Lot").unwrap();
675    }
676
677    #[test]
678    fn test_crossover_with_disjoint_rules() {
679        let mut interp_a = Interpreter::new();
680        interp_a.add_rule("A", parse_ops(r#"Extrude(5) I("Mesh")"#).unwrap());
681
682        let mut interp_b = Interpreter::new();
683        interp_b.add_rule("B", parse_ops(r#"Extrude(8) I("Mesh")"#).unwrap());
684
685        let dna_a = ShapeGenotype::from_interpreter(&interp_a);
686        let dna_b = ShapeGenotype::from_interpreter(&interp_b);
687        let mut rng = Pcg64::seed_from_u64(1);
688        let child = dna_a.crossover(&dna_b, &mut rng);
689        // Child must contain both disjoint rules.
690        assert!(child.rules.contains_key("A"));
691        assert!(child.rules.contains_key("B"));
692    }
693
694    #[test]
695    fn test_mutate_extrude_clamp() {
696        let mut interp = Interpreter::new();
697        interp.add_rule("R", parse_ops("Extrude(0.11) I(M)").unwrap());
698        let mut dna = ShapeGenotype::from_interpreter(&interp);
699        let mut rng = Pcg64::seed_from_u64(0);
700        // Mutate at rate 1.0 many times — Extrude must stay > 0.1.
701        for _ in 0..500 {
702            dna.mutate(&mut rng, 1.0);
703            let h = match &dna.rules["R"].variants[0].ops[0] {
704                ShapeOp::Extrude(h) => h.as_lit().unwrap(),
705                _ => panic!("expected Extrude"),
706            };
707            assert!(h >= 0.1, "Extrude height {h} < 0.1");
708        }
709    }
710
711    #[test]
712    fn test_blx_same_parents() {
713        // When a == b, BLX-α with alpha > 0 still stays near the parental value
714        // (d = 0, so lo_ext == hi_ext == a, result should be a).
715        use rand::SeedableRng;
716        let mut rng = Pcg64::seed_from_u64(42);
717        let result = blx(5.0, 5.0, 0.5, &mut rng);
718        assert!((result - 5.0).abs() < 1e-9);
719    }
720
721    /// Property test for issue #27: 1000 random heavy-mutation passes against a
722    /// rich grammar (every parametric op kind) must never produce a genotype that
723    /// fails to interpret. Each pass is also exercised against multiple footprints.
724    #[test]
725    fn test_mutate_property_1000_genotypes_all_interpret() {
726        use crate::scope::{Quat, Scope, Vec3};
727        use rand::SeedableRng;
728
729        // Grammar that exercises every parametric op kind so the property test
730        // covers Extrude, Taper, Scale, Translate, Split (all 3 SplitSize kinds),
731        // Repeat, and Roof (pitch + overhang).
732        let mut interp = Interpreter::new();
733        interp.add_rule(
734            "Lot",
735            parse_ops("Extrude(8) Split(Y) { 3: Floor | ~1: Mid | '0.2: Cap | 1.5: Top }").unwrap(),
736        );
737        interp.add_rule("Floor", parse_ops("Repeat(X, 2.0) { Bay }").unwrap());
738        interp.add_rule(
739            "Bay",
740            parse_ops(r#"Scale(0.9, 0.9, 0.9) Translate(0.1, 0, 0) I("Bay")"#).unwrap(),
741        );
742        interp.add_rule("Mid", parse_ops(r#"Taper(0.3) I("Mid")"#).unwrap());
743        interp.add_rule("Cap", parse_ops(r#"I("Cap")"#).unwrap());
744        interp.add_rule(
745            "Top",
746            parse_ops("Roof(Gable, 35) { Slope: Tile | GableEnd: Brick }").unwrap(),
747        );
748
749        let footprint = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 0.0, 6.0));
750
751        let dna_seed = ShapeGenotype::from_interpreter(&interp);
752        let mut failures: Vec<(u64, String)> = Vec::new();
753
754        for seed in 0u64..1000 {
755            let mut dna = dna_seed.clone();
756            let mut rng = Pcg64::seed_from_u64(seed);
757            // High rate (1.0) and multiple passes guarantee every parametric float
758            // is jittered many times, exercising the clamp boundaries hard.
759            for _ in 0..3 {
760                dna.mutate(&mut rng, 1.0);
761            }
762            let interp = dna.to_interpreter();
763            match interp.derive(footprint, "Lot") {
764                Ok(_) => {}
765                Err(e) => failures.push((seed, format!("{e:?}"))),
766            }
767        }
768
769        assert!(
770            failures.is_empty(),
771            "{} of 1000 mutated genotypes failed to interpret. First failures: {:?}",
772            failures.len(),
773            failures.iter().take(5).collect::<Vec<_>>(),
774        );
775    }
776}