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::interpreter::{Interpreter, WeightedVariant};
39use crate::ops::{ShapeOp, SplitSize};
40use crate::scope::Vec3;
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 → weighted variants.
54    pub rules: HashMap<String, Vec<WeightedVariant>>,
55}
56
57impl ShapeGenotype {
58    /// Snapshot the rule table from a live interpreter.
59    pub fn from_interpreter(interp: &Interpreter) -> Self {
60        Self {
61            rules: interp.rules().clone(),
62        }
63    }
64
65    /// Build a fresh [`Interpreter`] from this genotype.
66    ///
67    /// Does **not** copy `seed`, `max_depth`, or `max_terminals` — set those
68    /// on the returned interpreter if your grammar requires non-default limits.
69    pub fn to_interpreter(&self) -> Interpreter {
70        let mut interp = Interpreter::new();
71        for (name, variants) in &self.rules {
72            interp.set_variants(name.clone(), variants.clone());
73        }
74        interp
75    }
76}
77
78// ── Genotype impl ─────────────────────────────────────────────────────────────
79
80impl Genotype for ShapeGenotype {
81    /// Perturb parametric floats throughout the grammar.
82    ///
83    /// Each mutable float is independently tested against `rate`.  When
84    /// selected, Gaussian noise (Box-Muller) is applied and the result is
85    /// clamped to keep the grammar structurally valid:
86    ///
87    /// | Op | Parameter | σ | Clamp |
88    /// |---|---|---|---|
89    /// | `Extrude(h)` | h | 0.5 | > 0.1 |
90    /// | `Taper(t)` | t | 0.1 | [0, 1] |
91    /// | `Scale(v)` | each component | 0.2 | > 0.1 |
92    /// | `Translate(v)` | each component | 0.5 | none |
93    /// | `Split` slot sizes | size value | 0.3 | > 0.1 (or [0.01, 1] for Relative) |
94    /// | `Repeat` tile_size | tile_size | 0.3 | > 0.1 |
95    /// | `Roof` angle | angle (°) | 5.0 | [1, 89] |
96    /// | `Roof` overhang | overhang | 0.2 | [0, 2] |
97    ///
98    /// After per-slot jitter, each `Split` is repaired so that its absolute and
99    /// relative slot sums do not exceed their pre-mutation totals — this prevents
100    /// independent jitter from producing `SplitOverflow` errors at interpret time
101    /// (issue #27).
102    fn mutate<R: Rng>(&mut self, rng: &mut R, rate: f32) {
103        // Iterate by sorted key so the per-op RNG draws are deterministic for
104        // a given seed — `HashMap`'s default value iteration order is not.
105        let mut keys: Vec<&String> = self.rules.keys().collect();
106        keys.sort();
107        let keys: Vec<String> = keys.into_iter().cloned().collect();
108        for key in keys {
109            if let Some(variants) = self.rules.get_mut(&key) {
110                for variant in variants.iter_mut() {
111                    for op in variant.ops.iter_mut() {
112                        mutate_op(op, rng, rate);
113                    }
114                }
115            }
116        }
117    }
118
119    /// Homologous BLX-α crossover (α = 0.5).
120    ///
121    /// For each rule name shared by both parents:
122    /// - If both parents have the **same number of variants** and each
123    ///   variant pair has the **same op-sequence topology** (same variant
124    ///   discriminants in the same order), every float parameter is blended
125    ///   using BLX-α, producing offspring that explore slightly beyond the
126    ///   parental range.
127    /// - If the variant counts or topologies differ, the whole rule is
128    ///   inherited uniformly at random from one parent (50 / 50).
129    ///
130    /// Rules present in only one parent are passed through to the child
131    /// unchanged, so the child always has a complete, runnable grammar.
132    fn crossover<R: Rng>(&self, other: &Self, rng: &mut R) -> Self {
133        let mut child_rules = self.rules.clone();
134
135        for (name, self_variants) in &self.rules {
136            if let Some(other_variants) = other.rules.get(name) {
137                if self_variants.len() == other_variants.len() {
138                    let blended: Vec<WeightedVariant> = self_variants
139                        .iter()
140                        .zip(other_variants.iter())
141                        .map(|(sv, ov)| crossover_variant(sv, ov, rng))
142                        .collect();
143                    child_rules.insert(name.clone(), blended);
144                } else if rng.random::<f32>() < 0.5 {
145                    child_rules.insert(name.clone(), other_variants.clone());
146                }
147                // else: keep self's rule (already cloned into child_rules)
148            }
149        }
150
151        // Rules present only in `other` are added to the child.
152        for (name, variants) in &other.rules {
153            if !child_rules.contains_key(name) {
154                child_rules.insert(name.clone(), variants.clone());
155            }
156        }
157
158        ShapeGenotype { rules: child_rules }
159    }
160}
161
162// ── Gaussian jitter ───────────────────────────────────────────────────────────
163
164/// Returns `value + N(0, sigma)` with probability `rate`, else `value`.
165///
166/// Uses the Box-Muller transform for Gaussian sampling from two uniform draws.
167fn jitter<R: Rng>(rng: &mut R, rate: f32, value: f64, sigma: f64) -> f64 {
168    if rng.random::<f32>() < rate {
169        let u1: f64 = rng.random::<f64>().max(1e-15); // avoid log(0)
170        let u2: f64 = rng.random::<f64>();
171        let gauss = (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
172        value + sigma * gauss
173    } else {
174        value
175    }
176}
177
178// ── Per-op mutation ───────────────────────────────────────────────────────────
179
180fn mutate_op<R: Rng>(op: &mut ShapeOp, rng: &mut R, rate: f32) {
181    match op {
182        ShapeOp::Extrude(h) => {
183            *h = jitter(rng, rate, *h, 0.5).max(0.1);
184        }
185        ShapeOp::Taper(t) => {
186            *t = jitter(rng, rate, *t, 0.1).clamp(0.0, 1.0);
187        }
188        ShapeOp::Scale(v) => {
189            *v = Vec3::new(
190                jitter(rng, rate, v.x, 0.2).max(0.1),
191                jitter(rng, rate, v.y, 0.2).max(0.1),
192                jitter(rng, rate, v.z, 0.2).max(0.1),
193            );
194        }
195        ShapeOp::Translate(v) => {
196            *v = Vec3::new(
197                jitter(rng, rate, v.x, 0.5),
198                jitter(rng, rate, v.y, 0.5),
199                jitter(rng, rate, v.z, 0.5),
200            );
201        }
202        ShapeOp::Split { slots, .. } => {
203            // Snapshot pre-mutation sums so we can prevent absolute / relative
204            // sums from growing past their original totals. Without this guard
205            // independent per-slot Gaussian jitter can push Σ(absolute) past
206            // the scope dimension and trip `SplitOverflow` at interpret time.
207            let original_abs_sum = sum_split_size(slots, |s| matches!(s, SplitSize::Absolute(_)));
208            let original_rel_sum = sum_split_size(slots, |s| matches!(s, SplitSize::Relative(_)));
209            for slot in slots.iter_mut() {
210                mutate_split_size(&mut slot.size, rng, rate);
211            }
212            repair_split_sums(slots, original_abs_sum, original_rel_sum);
213        }
214        ShapeOp::Repeat { tile_sizes, .. } => {
215            for ts in tile_sizes.iter_mut() {
216                *ts = jitter(rng, rate, *ts, 0.3).max(0.1);
217            }
218        }
219        ShapeOp::Roof { config, .. } => {
220            config.pitch = jitter(rng, rate, config.pitch, 5.0).clamp(1.0, 89.0);
221            config.overhang = jitter(rng, rate, config.overhang, 0.2).clamp(0.0, 2.0);
222        }
223        ShapeOp::Polygon(verts) => {
224            for v in verts.iter_mut() {
225                v.x = jitter(rng, rate, v.x, 0.2);
226                v.y = jitter(rng, rate, v.y, 0.2);
227            }
228        }
229        // Non-parametric ops have no float to jitter.
230        ShapeOp::Rotate(_)
231        | ShapeOp::Comp(_)
232        | ShapeOp::Offset { .. }
233        | ShapeOp::I(_)
234        | ShapeOp::Mat(_)
235        | ShapeOp::Rule(_)
236        | ShapeOp::Align { .. }
237        | ShapeOp::Attach { .. }
238        | ShapeOp::RegSnap(_)
239        | ShapeOp::IfClear { .. }
240        | ShapeOp::IfOccluded { .. } => {}
241    }
242}
243
244fn mutate_split_size<R: Rng>(size: &mut SplitSize, rng: &mut R, rate: f32) {
245    match size {
246        SplitSize::Absolute(v) => *v = jitter(rng, rate, *v, 0.3).max(0.1),
247        SplitSize::Relative(v) => *v = jitter(rng, rate, *v, 0.05).clamp(0.01, 1.0),
248        SplitSize::Floating(v) => *v = jitter(rng, rate, *v, 0.3).max(0.1),
249    }
250}
251
252/// Sums size values of slots whose `SplitSize` matches `kind`.
253fn sum_split_size<F>(slots: &[crate::ops::SplitSlot], kind: F) -> f64
254where
255    F: Fn(&SplitSize) -> bool,
256{
257    slots
258        .iter()
259        .filter(|s| kind(&s.size))
260        .map(|s| match s.size {
261            SplitSize::Absolute(v) | SplitSize::Relative(v) | SplitSize::Floating(v) => v,
262        })
263        .sum()
264}
265
266/// Prevents Split absolute / relative slot sums from growing past their
267/// pre-mutation totals. Each affected slot is scaled down proportionally,
268/// preserving the relative weights chosen by the mutation.
269fn repair_split_sums(
270    slots: &mut [crate::ops::SplitSlot],
271    original_abs_sum: f64,
272    original_rel_sum: f64,
273) {
274    let new_abs_sum = sum_split_size(slots, |s| matches!(s, SplitSize::Absolute(_)));
275    if original_abs_sum > 1e-9 && new_abs_sum > original_abs_sum {
276        let scale = original_abs_sum / new_abs_sum;
277        for slot in slots.iter_mut() {
278            if let SplitSize::Absolute(v) = &mut slot.size {
279                *v = (*v * scale).max(0.1);
280            }
281        }
282    }
283    let new_rel_sum = sum_split_size(slots, |s| matches!(s, SplitSize::Relative(_)));
284    if original_rel_sum > 1e-9 && new_rel_sum > original_rel_sum {
285        let scale = original_rel_sum / new_rel_sum;
286        for slot in slots.iter_mut() {
287            if let SplitSize::Relative(v) = &mut slot.size {
288                *v = (*v * scale).clamp(0.01, 1.0);
289            }
290        }
291    }
292}
293
294// ── Per-variant crossover ─────────────────────────────────────────────────────
295
296fn crossover_variant<R: Rng>(
297    a: &WeightedVariant,
298    b: &WeightedVariant,
299    rng: &mut R,
300) -> WeightedVariant {
301    if same_structure(&a.ops, &b.ops) {
302        let ops = a
303            .ops
304            .iter()
305            .zip(b.ops.iter())
306            .map(|(ao, bo)| blend_op(ao, bo, rng))
307            .collect();
308        WeightedVariant {
309            weight: blx(a.weight, b.weight, 0.5, rng).max(0.0),
310            ops,
311        }
312    } else {
313        // Topologies differ — uniform crossover: pick one parent whole.
314        if rng.random::<f32>() < 0.5 {
315            a.clone()
316        } else {
317            b.clone()
318        }
319    }
320}
321
322/// Returns `true` when `a` and `b` have identical ShapeOp variant sequences.
323fn same_structure(a: &[ShapeOp], b: &[ShapeOp]) -> bool {
324    a.len() == b.len() && a.iter().zip(b.iter()).all(|(ao, bo)| same_op_kind(ao, bo))
325}
326
327fn same_op_kind(a: &ShapeOp, b: &ShapeOp) -> bool {
328    use ShapeOp::*;
329    matches!(
330        (a, b),
331        (Extrude(_), Extrude(_))
332            | (Taper(_), Taper(_))
333            | (Rotate(_), Rotate(_))
334            | (Translate(_), Translate(_))
335            | (Scale(_), Scale(_))
336            | (Split { .. }, Split { .. })
337            | (Repeat { .. }, Repeat { .. })
338            | (Comp(_), Comp(_))
339            | (I(_), I(_))
340            | (Mat(_), Mat(_))
341            | (Rule(_), Rule(_))
342            | (Align { .. }, Align { .. })
343            | (Offset { .. }, Offset { .. })
344            | (Roof { .. }, Roof { .. })
345            | (Attach { .. }, Attach { .. })
346            | (Polygon(_), Polygon(_))
347    )
348}
349
350// ── BLX-α blend ──────────────────────────────────────────────────────────────
351
352/// BLX-α blend: samples uniformly from `[min − α·d, max + α·d]` where `d = max − min`.
353///
354/// With α = 0.0 this reduces to uniform crossover in the parental range.
355/// With α = 0.5 (the default used here) it allows moderate exploration beyond parents.
356fn blx<R: Rng>(a: f64, b: f64, alpha: f64, rng: &mut R) -> f64 {
357    let lo = a.min(b);
358    let hi = a.max(b);
359    let d = (hi - lo) * alpha;
360    let lo_ext = lo - d;
361    let hi_ext = hi + d;
362    if hi_ext <= lo_ext {
363        (a + b) / 2.0
364    } else {
365        rng.random::<f64>() * (hi_ext - lo_ext) + lo_ext
366    }
367}
368
369fn blend_op<R: Rng>(a: &ShapeOp, b: &ShapeOp, rng: &mut R) -> ShapeOp {
370    match (a, b) {
371        (ShapeOp::Extrude(ha), ShapeOp::Extrude(hb)) => {
372            ShapeOp::Extrude(blx(*ha, *hb, 0.5, rng).max(0.1))
373        }
374        (ShapeOp::Taper(ta), ShapeOp::Taper(tb)) => {
375            ShapeOp::Taper(blx(*ta, *tb, 0.5, rng).clamp(0.0, 1.0))
376        }
377        (ShapeOp::Scale(va), ShapeOp::Scale(vb)) => ShapeOp::Scale(Vec3::new(
378            blx(va.x, vb.x, 0.5, rng).max(0.1),
379            blx(va.y, vb.y, 0.5, rng).max(0.1),
380            blx(va.z, vb.z, 0.5, rng).max(0.1),
381        )),
382        (ShapeOp::Translate(va), ShapeOp::Translate(vb)) => ShapeOp::Translate(Vec3::new(
383            blx(va.x, vb.x, 0.5, rng),
384            blx(va.y, vb.y, 0.5, rng),
385            blx(va.z, vb.z, 0.5, rng),
386        )),
387        (
388            ShapeOp::Split {
389                axis,
390                slots: slots_a,
391                snap,
392            },
393            ShapeOp::Split { slots: slots_b, .. },
394        ) => {
395            // Blend slot sizes pairwise; keep rules, axis and snap binding from parent A.
396            let slots = if slots_a.len() == slots_b.len() {
397                slots_a
398                    .iter()
399                    .zip(slots_b.iter())
400                    .map(|(sa, sb)| crate::ops::SplitSlot {
401                        size: blend_split_size(&sa.size, &sb.size, rng),
402                        rule: sa.rule.clone(),
403                    })
404                    .collect()
405            } else {
406                slots_a.clone()
407            };
408            ShapeOp::Split {
409                axis: *axis,
410                slots,
411                snap: snap.clone(),
412            }
413        }
414        (
415            ShapeOp::Repeat {
416                axis,
417                tile_sizes: tsa,
418                rule,
419            },
420            ShapeOp::Repeat {
421                tile_sizes: tsb, ..
422            },
423        ) => {
424            let blended: Vec<f64> = if tsa.len() == tsb.len() {
425                tsa.iter()
426                    .zip(tsb.iter())
427                    .map(|(a, b)| blx(*a, *b, 0.5, rng).max(0.1))
428                    .collect()
429            } else {
430                tsa.clone()
431            };
432            ShapeOp::Repeat {
433                axis: *axis,
434                tile_sizes: blended,
435                rule: rule.clone(),
436            }
437        }
438        (ShapeOp::Roof { config: ca, cases }, ShapeOp::Roof { config: cb, .. }) => {
439            let mut config = ca.clone();
440            config.pitch = blx(ca.pitch, cb.pitch, 0.5, rng).clamp(1.0, 89.0);
441            config.overhang = blx(ca.overhang, cb.overhang, 0.5, rng).clamp(0.0, 2.0);
442            ShapeOp::Roof {
443                config,
444                cases: cases.clone(),
445            }
446        }
447        // Non-parametric or unblendable: use parent A unchanged.
448        _ => a.clone(),
449    }
450}
451
452fn blend_split_size<R: Rng>(a: &SplitSize, b: &SplitSize, rng: &mut R) -> SplitSize {
453    match (a, b) {
454        (SplitSize::Absolute(va), SplitSize::Absolute(vb)) => {
455            SplitSize::Absolute(blx(*va, *vb, 0.5, rng).max(0.1))
456        }
457        (SplitSize::Relative(va), SplitSize::Relative(vb)) => {
458            SplitSize::Relative(blx(*va, *vb, 0.5, rng).clamp(0.01, 1.0))
459        }
460        (SplitSize::Floating(va), SplitSize::Floating(vb)) => {
461            SplitSize::Floating(blx(*va, *vb, 0.5, rng).max(0.1))
462        }
463        // Mixed SplitSize kinds: keep parent A's.
464        _ => a.clone(),
465    }
466}
467
468// ── Tests ─────────────────────────────────────────────────────────────────────
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use crate::grammar::parse_ops;
474    use rand::SeedableRng;
475    use rand_pcg::Pcg64;
476
477    fn build_interp() -> Interpreter {
478        let mut interp = Interpreter::new();
479        interp.add_rule(
480            "Lot",
481            parse_ops("Extrude(10) Split(Y) { 3: Floor | ~1: Top }").unwrap(),
482        );
483        interp.add_rule("Floor", parse_ops(r#"Taper(0.0) I("Floor")"#).unwrap());
484        interp.add_rule("Top", parse_ops(r#"Taper(0.8) I("Roof")"#).unwrap());
485        interp
486    }
487
488    #[test]
489    fn test_round_trip() {
490        let interp = build_interp();
491        let dna = ShapeGenotype::from_interpreter(&interp);
492        let interp2 = dna.to_interpreter();
493        // Both should derive the same shape model.
494        let footprint = crate::scope::Scope::new(
495            Vec3::ZERO,
496            crate::scope::Quat::IDENTITY,
497            Vec3::new(10.0, 0.0, 10.0),
498        );
499        let m1 = interp.derive(footprint, "Lot").unwrap();
500        let m2 = interp2.derive(footprint, "Lot").unwrap();
501        assert_eq!(m1.len(), m2.len());
502        assert_eq!(m1.terminals[0].mesh_id, m2.terminals[0].mesh_id);
503    }
504
505    #[test]
506    fn test_mutate_preserves_validity() {
507        let interp = build_interp();
508        let mut dna = ShapeGenotype::from_interpreter(&interp);
509        let mut rng = Pcg64::seed_from_u64(7);
510        // High rate to exercise most code paths.
511        dna.mutate(&mut rng, 1.0);
512        let interp2 = dna.to_interpreter();
513        let footprint = crate::scope::Scope::new(
514            Vec3::ZERO,
515            crate::scope::Quat::IDENTITY,
516            Vec3::new(10.0, 0.0, 10.0),
517        );
518        // Should still derive without error.
519        interp2.derive(footprint, "Lot").unwrap();
520    }
521
522    #[test]
523    fn test_crossover_produces_valid_grammar() {
524        let interp_a = build_interp();
525        let mut interp_b = build_interp();
526        // Give parent B different parameters.
527        interp_b.add_rule(
528            "Lot",
529            parse_ops("Extrude(20) Split(Y) { 5: Floor | ~2: Top }").unwrap(),
530        );
531
532        let dna_a = ShapeGenotype::from_interpreter(&interp_a);
533        let dna_b = ShapeGenotype::from_interpreter(&interp_b);
534        let mut rng = Pcg64::seed_from_u64(99);
535        let child = dna_a.crossover(&dna_b, &mut rng);
536        let interp_child = child.to_interpreter();
537
538        let footprint = crate::scope::Scope::new(
539            Vec3::ZERO,
540            crate::scope::Quat::IDENTITY,
541            Vec3::new(10.0, 0.0, 10.0),
542        );
543        interp_child.derive(footprint, "Lot").unwrap();
544    }
545
546    #[test]
547    fn test_crossover_with_disjoint_rules() {
548        let mut interp_a = Interpreter::new();
549        interp_a.add_rule("A", parse_ops(r#"Extrude(5) I("Mesh")"#).unwrap());
550
551        let mut interp_b = Interpreter::new();
552        interp_b.add_rule("B", parse_ops(r#"Extrude(8) I("Mesh")"#).unwrap());
553
554        let dna_a = ShapeGenotype::from_interpreter(&interp_a);
555        let dna_b = ShapeGenotype::from_interpreter(&interp_b);
556        let mut rng = Pcg64::seed_from_u64(1);
557        let child = dna_a.crossover(&dna_b, &mut rng);
558        // Child must contain both disjoint rules.
559        assert!(child.rules.contains_key("A"));
560        assert!(child.rules.contains_key("B"));
561    }
562
563    #[test]
564    fn test_mutate_extrude_clamp() {
565        let mut interp = Interpreter::new();
566        interp.add_rule("R", parse_ops("Extrude(0.11) I(M)").unwrap());
567        let mut dna = ShapeGenotype::from_interpreter(&interp);
568        let mut rng = Pcg64::seed_from_u64(0);
569        // Mutate at rate 1.0 many times — Extrude must stay > 0.1.
570        for _ in 0..500 {
571            dna.mutate(&mut rng, 1.0);
572            let h = match &dna.rules["R"][0].ops[0] {
573                ShapeOp::Extrude(h) => *h,
574                _ => panic!("expected Extrude"),
575            };
576            assert!(h >= 0.1, "Extrude height {h} < 0.1");
577        }
578    }
579
580    #[test]
581    fn test_blx_same_parents() {
582        // When a == b, BLX-α with alpha > 0 still stays near the parental value
583        // (d = 0, so lo_ext == hi_ext == a, result should be a).
584        use rand::SeedableRng;
585        let mut rng = Pcg64::seed_from_u64(42);
586        let result = blx(5.0, 5.0, 0.5, &mut rng);
587        assert!((result - 5.0).abs() < 1e-9);
588    }
589
590    /// Property test for issue #27: 1000 random heavy-mutation passes against a
591    /// rich grammar (every parametric op kind) must never produce a genotype that
592    /// fails to interpret. Each pass is also exercised against multiple footprints.
593    #[test]
594    fn test_mutate_property_1000_genotypes_all_interpret() {
595        use crate::scope::{Quat, Scope, Vec3};
596        use rand::SeedableRng;
597
598        // Grammar that exercises every parametric op kind so the property test
599        // covers Extrude, Taper, Scale, Translate, Split (all 3 SplitSize kinds),
600        // Repeat, and Roof (pitch + overhang).
601        let mut interp = Interpreter::new();
602        interp.add_rule(
603            "Lot",
604            parse_ops("Extrude(8) Split(Y) { 3: Floor | ~1: Mid | '0.2: Cap | 1.5: Top }").unwrap(),
605        );
606        interp.add_rule("Floor", parse_ops("Repeat(X, 2.0) { Bay }").unwrap());
607        interp.add_rule(
608            "Bay",
609            parse_ops(r#"Scale(0.9, 0.9, 0.9) Translate(0.1, 0, 0) I("Bay")"#).unwrap(),
610        );
611        interp.add_rule("Mid", parse_ops(r#"Taper(0.3) I("Mid")"#).unwrap());
612        interp.add_rule("Cap", parse_ops(r#"I("Cap")"#).unwrap());
613        interp.add_rule(
614            "Top",
615            parse_ops("Roof(Gable, 35) { Slope: Tile | GableEnd: Brick }").unwrap(),
616        );
617
618        let footprint = Scope::new(Vec3::ZERO, Quat::IDENTITY, Vec3::new(10.0, 0.0, 6.0));
619
620        let dna_seed = ShapeGenotype::from_interpreter(&interp);
621        let mut failures: Vec<(u64, String)> = Vec::new();
622
623        for seed in 0u64..1000 {
624            let mut dna = dna_seed.clone();
625            let mut rng = Pcg64::seed_from_u64(seed);
626            // High rate (1.0) and multiple passes guarantee every parametric float
627            // is jittered many times, exercising the clamp boundaries hard.
628            for _ in 0..3 {
629                dna.mutate(&mut rng, 1.0);
630            }
631            let interp = dna.to_interpreter();
632            match interp.derive(footprint, "Lot") {
633                Ok(_) => {}
634                Err(e) => failures.push((seed, format!("{e:?}"))),
635            }
636        }
637
638        assert!(
639            failures.is_empty(),
640            "{} of 1000 mutated genotypes failed to interpret. First failures: {:?}",
641            failures.len(),
642            failures.iter().take(5).collect::<Vec<_>>(),
643        );
644    }
645}