Skip to main content

oxihuman_morph/
genetic.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5#![allow(clippy::too_many_arguments)]
6
7//! Genetic body parameter inheritance and trait blending for OxiHuman.
8//!
9//! Provides Mendelian-style discrete inheritance, continuous blending,
10//! crossover masking, and population-level diversity scoring.
11
12use std::collections::HashMap;
13
14// ---------------------------------------------------------------------------
15// Data structures
16// ---------------------------------------------------------------------------
17
18/// A set of body parameters representing one parent's genetic contribution.
19#[derive(Debug, Clone)]
20pub struct GeneticParams {
21    pub height: f32,
22    pub weight: f32,
23    pub muscle: f32,
24    pub age: f32,
25    /// Arbitrary named extra parameters (e.g. "nose_width", "jaw_size").
26    pub extra: HashMap<String, f32>,
27}
28
29impl GeneticParams {
30    /// Create a zeroed `GeneticParams`.
31    pub fn new() -> Self {
32        Self {
33            height: 0.0,
34            weight: 0.0,
35            muscle: 0.0,
36            age: 0.0,
37            extra: HashMap::new(),
38        }
39    }
40}
41
42impl Default for GeneticParams {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48// ---------------------------------------------------------------------------
49
50/// Named individual defined by two parents and dominance / seed settings.
51#[derive(Debug, Clone)]
52pub struct GeneticProfile {
53    pub name: String,
54    pub parent_a: GeneticParams,
55    pub parent_b: GeneticParams,
56    /// Blend weight for parent A (0.0 = all B, 1.0 = all A). Default 0.5.
57    pub dominance: f32,
58    /// Optional random seed for stochastic trait variation.
59    pub seed: Option<u32>,
60}
61
62impl GeneticProfile {
63    /// Construct a new profile with equal dominance and no seed.
64    pub fn new(name: impl Into<String>, parent_a: GeneticParams, parent_b: GeneticParams) -> Self {
65        Self {
66            name: name.into(),
67            parent_a,
68            parent_b,
69            dominance: 0.5,
70            seed: None,
71        }
72    }
73}
74
75// ---------------------------------------------------------------------------
76
77/// A collection of [`GeneticProfile`] instances representing a population.
78#[derive(Debug, Clone, Default)]
79pub struct GeneticPopulation {
80    pub profiles: Vec<GeneticProfile>,
81}
82
83impl GeneticPopulation {
84    /// Create an empty population.
85    pub fn new() -> Self {
86        Self {
87            profiles: Vec::new(),
88        }
89    }
90
91    /// Add a profile to the population.
92    pub fn add(&mut self, profile: GeneticProfile) {
93        self.profiles.push(profile);
94    }
95
96    /// Number of profiles in the population.
97    pub fn count(&self) -> usize {
98        self.profiles.len()
99    }
100
101    /// Compute the dominant blend for every profile and return the results.
102    pub fn blend_all(&self) -> Vec<GeneticParams> {
103        self.profiles.iter().map(dominant_blend).collect()
104    }
105
106    /// Mean pairwise L2 distance of all blended results.
107    ///
108    /// Returns `0.0` if the population has fewer than two members.
109    pub fn diversity_score(&self) -> f32 {
110        let blended = self.blend_all();
111        let n = blended.len();
112        if n < 2 {
113            return 0.0;
114        }
115        let mut total = 0.0_f32;
116        let mut count = 0u32;
117        for i in 0..n {
118            for j in (i + 1)..n {
119                total += params_distance(&blended[i], &blended[j]);
120                count += 1;
121            }
122        }
123        if count == 0 {
124            0.0
125        } else {
126            total / count as f32
127        }
128    }
129}
130
131// ---------------------------------------------------------------------------
132// Utility functions
133// ---------------------------------------------------------------------------
134
135/// Simple Linear Congruential Generator producing values in `[0, 1)`.
136///
137/// Parameters: multiplier 1664525, increment 1013904223 (Numerical Recipes).
138pub fn lcg_f32(seed: &mut u32) -> f32 {
139    *seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
140    // Use the upper 23 bits for the mantissa of a float in [0, 1).
141    (*seed >> 9) as f32 / (1u32 << 23) as f32
142}
143
144/// L2 distance over the four core fields (height, weight, muscle, age).
145pub fn params_distance(a: &GeneticParams, b: &GeneticParams) -> f32 {
146    let dh = a.height - b.height;
147    let dw = a.weight - b.weight;
148    let dm = a.muscle - b.muscle;
149    let da = a.age - b.age;
150    (dh * dh + dw * dw + dm * dm + da * da).sqrt()
151}
152
153/// Clamp all core fields and every extra value to `[0, 1]`.
154pub fn clamp_params(p: &mut GeneticParams) {
155    p.height = p.height.clamp(0.0, 1.0);
156    p.weight = p.weight.clamp(0.0, 1.0);
157    p.muscle = p.muscle.clamp(0.0, 1.0);
158    p.age = p.age.clamp(0.0, 1.0);
159    for v in p.extra.values_mut() {
160        *v = v.clamp(0.0, 1.0);
161    }
162}
163
164/// Arithmetic mean of a slice of params.
165///
166/// Returns `None` if the slice is empty.
167pub fn average_params(params: &[GeneticParams]) -> Option<GeneticParams> {
168    if params.is_empty() {
169        return None;
170    }
171    let n = params.len() as f32;
172    let mut acc = GeneticParams::new();
173    for p in params {
174        acc.height += p.height;
175        acc.weight += p.weight;
176        acc.muscle += p.muscle;
177        acc.age += p.age;
178        for (k, v) in &p.extra {
179            *acc.extra.entry(k.clone()).or_insert(0.0) += v;
180        }
181    }
182    acc.height /= n;
183    acc.weight /= n;
184    acc.muscle /= n;
185    acc.age /= n;
186    for v in acc.extra.values_mut() {
187        *v /= n;
188    }
189    Some(acc)
190}
191
192// ---------------------------------------------------------------------------
193// Core blending functions
194// ---------------------------------------------------------------------------
195
196/// Linear interpolation between two param sets.
197///
198/// `t = 0.0` returns B; `t = 1.0` returns A.  
199/// Extra keys present in both parents are blended; keys present in only one
200/// parent are carried over unchanged at the appropriate weight boundary.
201pub fn blend_params(a: &GeneticParams, b: &GeneticParams, t: f32) -> GeneticParams {
202    let lerp = |va: f32, vb: f32| va * t + vb * (1.0 - t);
203
204    let mut extra: HashMap<String, f32> = HashMap::new();
205
206    // Keys from A
207    for (k, va) in &a.extra {
208        let vb = b.extra.get(k).copied().unwrap_or(0.0);
209        extra.insert(k.clone(), lerp(*va, vb));
210    }
211    // Keys only in B
212    for (k, vb) in &b.extra {
213        if !a.extra.contains_key(k) {
214            extra.insert(k.clone(), lerp(0.0, *vb));
215        }
216    }
217
218    GeneticParams {
219        height: lerp(a.height, b.height),
220        weight: lerp(a.weight, b.weight),
221        muscle: lerp(a.muscle, b.muscle),
222        age: lerp(a.age, b.age),
223        extra,
224    }
225}
226
227/// Blend using the profile's `dominance` weight, with optional noise.
228///
229/// When `profile.seed` is `Some(s)`, ±2.5 % noise (up to ±5 % range) is
230/// added to each of the four core fields, and the result is clamped to
231/// `[0, 1]`.
232pub fn dominant_blend(profile: &GeneticProfile) -> GeneticParams {
233    let mut result = blend_params(&profile.parent_a, &profile.parent_b, profile.dominance);
234
235    if let Some(s) = profile.seed {
236        let mut s_local = s;
237        let noise_scale = 0.05_f32;
238        result.height += (lcg_f32(&mut s_local) - 0.5) * noise_scale;
239        result.weight += (lcg_f32(&mut s_local) - 0.5) * noise_scale;
240        result.muscle += (lcg_f32(&mut s_local) - 0.5) * noise_scale;
241        result.age += (lcg_f32(&mut s_local) - 0.5) * noise_scale;
242        clamp_params(&mut result);
243    }
244
245    result
246}
247
248/// Discrete Mendelian inheritance: for each field, flip an LCG coin and pick
249/// either parent A's or parent B's value.
250pub fn inherit_random(profile: &GeneticProfile, seed: u32) -> GeneticParams {
251    let mut s = seed;
252
253    let pick = |va: f32, vb: f32, s: &mut u32| -> f32 {
254        if lcg_f32(s) >= 0.5 {
255            va
256        } else {
257            vb
258        }
259    };
260
261    let height = pick(profile.parent_a.height, profile.parent_b.height, &mut s);
262    let weight = pick(profile.parent_a.weight, profile.parent_b.weight, &mut s);
263    let muscle = pick(profile.parent_a.muscle, profile.parent_b.muscle, &mut s);
264    let age = pick(profile.parent_a.age, profile.parent_b.age, &mut s);
265
266    // For extra keys: union of both parents; coin flip per key.
267    let mut extra: HashMap<String, f32> = HashMap::new();
268    let mut all_keys: Vec<String> = profile.parent_a.extra.keys().cloned().collect();
269    for k in profile.parent_b.extra.keys() {
270        if !profile.parent_a.extra.contains_key(k) {
271            all_keys.push(k.clone());
272        }
273    }
274    for k in all_keys {
275        let va = profile.parent_a.extra.get(&k).copied().unwrap_or(0.0);
276        let vb = profile.parent_b.extra.get(&k).copied().unwrap_or(0.0);
277        extra.insert(k, pick(va, vb, &mut s));
278    }
279
280    GeneticParams {
281        height,
282        weight,
283        muscle,
284        age,
285        extra,
286    }
287}
288
289/// Bitmask-driven inheritance.
290///
291/// | Bit | Field  |
292/// |-----|--------|
293/// | 0   | height |
294/// | 1   | weight |
295/// | 2   | muscle |
296/// | 3   | age    |
297///
298/// If a bit is **set** the value comes from `a`; otherwise from `b`.
299/// `extra` keys follow bit 0 (height) as a tie-breaker for simplicity.
300pub fn crossover_blend(a: &GeneticParams, b: &GeneticParams, crossover_mask: u64) -> GeneticParams {
301    let pick = |va: f32, vb: f32, bit: u64| -> f32 {
302        if (crossover_mask >> bit) & 1 == 1 {
303            va
304        } else {
305            vb
306        }
307    };
308
309    let height = pick(a.height, b.height, 0);
310    let weight = pick(a.weight, b.weight, 1);
311    let muscle = pick(a.muscle, b.muscle, 2);
312    let age = pick(a.age, b.age, 3);
313
314    let mut extra: HashMap<String, f32> = HashMap::new();
315    for k in a.extra.keys().chain(b.extra.keys()) {
316        if extra.contains_key(k) {
317            continue;
318        }
319        let va = a.extra.get(k).copied().unwrap_or(0.0);
320        let vb = b.extra.get(k).copied().unwrap_or(0.0);
321        // Extra keys inherit from whichever side bit 0 selects.
322        extra.insert(k.clone(), pick(va, vb, 0));
323    }
324
325    GeneticParams {
326        height,
327        weight,
328        muscle,
329        age,
330        extra,
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Tests
336// ---------------------------------------------------------------------------
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn make_a() -> GeneticParams {
343        let mut a = GeneticParams::new();
344        a.height = 1.0;
345        a.weight = 0.8;
346        a.muscle = 0.6;
347        a.age = 0.4;
348        a.extra.insert("nose".to_string(), 0.9);
349        a
350    }
351
352    fn make_b() -> GeneticParams {
353        let mut b = GeneticParams::new();
354        b.height = 0.0;
355        b.weight = 0.2;
356        b.muscle = 0.4;
357        b.age = 0.6;
358        b.extra.insert("nose".to_string(), 0.1);
359        b
360    }
361
362    fn make_profile(dominance: f32, seed: Option<u32>) -> GeneticProfile {
363        GeneticProfile {
364            name: "test".to_string(),
365            parent_a: make_a(),
366            parent_b: make_b(),
367            dominance,
368            seed,
369        }
370    }
371
372    #[test]
373    fn test_genetic_params_default() {
374        let p = GeneticParams::default();
375        assert_eq!(p.height, 0.0);
376        assert_eq!(p.weight, 0.0);
377        assert_eq!(p.muscle, 0.0);
378        assert_eq!(p.age, 0.0);
379        assert!(p.extra.is_empty());
380    }
381
382    #[test]
383    fn test_blend_params_midpoint() {
384        let a = make_a();
385        let b = make_b();
386        let mid = blend_params(&a, &b, 0.5);
387        assert!((mid.height - 0.5).abs() < 1e-5);
388        assert!((mid.weight - 0.5).abs() < 1e-5);
389        assert!((mid.muscle - 0.5).abs() < 1e-5);
390        assert!((mid.age - 0.5).abs() < 1e-5);
391        assert!((mid.extra["nose"] - 0.5).abs() < 1e-5);
392    }
393
394    #[test]
395    fn test_blend_params_full_a() {
396        let a = make_a();
397        let b = make_b();
398        let result = blend_params(&a, &b, 1.0);
399        assert!((result.height - a.height).abs() < 1e-5);
400        assert!((result.weight - a.weight).abs() < 1e-5);
401        assert!((result.muscle - a.muscle).abs() < 1e-5);
402        assert!((result.age - a.age).abs() < 1e-5);
403    }
404
405    #[test]
406    fn test_blend_params_full_b() {
407        let a = make_a();
408        let b = make_b();
409        let result = blend_params(&a, &b, 0.0);
410        assert!((result.height - b.height).abs() < 1e-5);
411        assert!((result.weight - b.weight).abs() < 1e-5);
412        assert!((result.muscle - b.muscle).abs() < 1e-5);
413        assert!((result.age - b.age).abs() < 1e-5);
414    }
415
416    #[test]
417    fn test_dominant_blend_no_seed() {
418        let profile = make_profile(1.0, None);
419        let result = dominant_blend(&profile);
420        // dominance = 1.0 means pure A
421        assert!((result.height - 1.0).abs() < 1e-5);
422        assert!((result.weight - 0.8).abs() < 1e-5);
423    }
424
425    #[test]
426    fn test_dominant_blend_with_seed() {
427        let profile = make_profile(0.5, Some(42));
428        let result = dominant_blend(&profile);
429        // All fields should be clamped to [0, 1]
430        assert!(result.height >= 0.0 && result.height <= 1.0);
431        assert!(result.weight >= 0.0 && result.weight <= 1.0);
432        assert!(result.muscle >= 0.0 && result.muscle <= 1.0);
433        assert!(result.age >= 0.0 && result.age <= 1.0);
434        // Should differ from the no-seed version (noise applied)
435        let profile_no_seed = make_profile(0.5, None);
436        let no_seed = dominant_blend(&profile_no_seed);
437        // At least one field should differ (with high probability for seed 42)
438        let differs = (result.height - no_seed.height).abs() > 1e-6
439            || (result.weight - no_seed.weight).abs() > 1e-6
440            || (result.muscle - no_seed.muscle).abs() > 1e-6
441            || (result.age - no_seed.age).abs() > 1e-6;
442        assert!(differs, "noise should affect at least one field");
443    }
444
445    #[test]
446    fn test_inherit_random_valid_range() {
447        let profile = make_profile(0.5, None);
448        let result = inherit_random(&profile, 1234);
449        // Each field must be exactly one of the parent values
450        let valid_h = result.height == 1.0 || result.height == 0.0;
451        let valid_w = result.weight == 0.8 || result.weight == 0.2;
452        let valid_m = result.muscle == 0.6 || result.muscle == 0.4;
453        let valid_a = result.age == 0.4 || result.age == 0.6;
454        assert!(valid_h, "height must be from one of the parents");
455        assert!(valid_w, "weight must be from one of the parents");
456        assert!(valid_m, "muscle must be from one of the parents");
457        assert!(valid_a, "age must be from one of the parents");
458    }
459
460    #[test]
461    fn test_crossover_blend_all_a() {
462        let a = make_a();
463        let b = make_b();
464        // Bits 0-3 all set → all from A
465        let result = crossover_blend(&a, &b, 0b1111);
466        assert!((result.height - a.height).abs() < 1e-5);
467        assert!((result.weight - a.weight).abs() < 1e-5);
468        assert!((result.muscle - a.muscle).abs() < 1e-5);
469        assert!((result.age - a.age).abs() < 1e-5);
470    }
471
472    #[test]
473    fn test_crossover_blend_all_b() {
474        let a = make_a();
475        let b = make_b();
476        // No bits set → all from B
477        let result = crossover_blend(&a, &b, 0b0000);
478        assert!((result.height - b.height).abs() < 1e-5);
479        assert!((result.weight - b.weight).abs() < 1e-5);
480        assert!((result.muscle - b.muscle).abs() < 1e-5);
481        assert!((result.age - b.age).abs() < 1e-5);
482    }
483
484    #[test]
485    fn test_crossover_blend_mixed() {
486        let a = make_a();
487        let b = make_b();
488        // bit0=height from A, bit1=weight from B, bit2=muscle from A, bit3=age from B
489        // mask = 0b0101 = 5
490        let result = crossover_blend(&a, &b, 0b0101);
491        assert!(
492            (result.height - a.height).abs() < 1e-5,
493            "bit0 set → height from A"
494        );
495        assert!(
496            (result.weight - b.weight).abs() < 1e-5,
497            "bit1 clear → weight from B"
498        );
499        assert!(
500            (result.muscle - a.muscle).abs() < 1e-5,
501            "bit2 set → muscle from A"
502        );
503        assert!((result.age - b.age).abs() < 1e-5, "bit3 clear → age from B");
504    }
505
506    #[test]
507    fn test_genetic_population() {
508        let mut pop = GeneticPopulation::new();
509        assert_eq!(pop.count(), 0);
510
511        pop.add(make_profile(0.3, None));
512        pop.add(make_profile(0.7, None));
513        pop.add(make_profile(0.5, Some(99)));
514        assert_eq!(pop.count(), 3);
515
516        let blended = pop.blend_all();
517        assert_eq!(blended.len(), 3);
518
519        // All blended results should have valid height values
520        for bp in &blended {
521            assert!(bp.height >= 0.0 && bp.height <= 1.0);
522        }
523    }
524
525    #[test]
526    fn test_diversity_score_identical() {
527        let mut pop = GeneticPopulation::new();
528        // Two identical profiles → distance = 0
529        pop.add(make_profile(0.5, None));
530        pop.add(make_profile(0.5, None));
531        let score = pop.diversity_score();
532        assert!(score.abs() < 1e-5, "identical profiles → diversity = 0");
533    }
534
535    #[test]
536    fn test_params_distance() {
537        let a = make_a();
538        let b = make_b();
539        let d = params_distance(&a, &b);
540        // height diff = 1, weight diff = 0.6, muscle diff = 0.2, age diff = 0.2
541        let expected = (1.0_f32 * 1.0 + 0.6 * 0.6 + 0.2 * 0.2 + 0.2 * 0.2_f32).sqrt();
542        assert!(
543            (d - expected).abs() < 1e-4,
544            "L2 distance mismatch: got {d}, expected {expected}"
545        );
546
547        // Distance from a param to itself is 0
548        assert!(params_distance(&a, &a).abs() < 1e-6);
549    }
550
551    #[test]
552    fn test_clamp_params() {
553        let mut p = GeneticParams {
554            height: 1.5,
555            weight: -0.3,
556            muscle: 0.5,
557            age: 2.0,
558            extra: {
559                let mut m = HashMap::new();
560                m.insert("x".to_string(), -1.0);
561                m.insert("y".to_string(), 3.0);
562                m
563            },
564        };
565        clamp_params(&mut p);
566        assert_eq!(p.height, 1.0);
567        assert_eq!(p.weight, 0.0);
568        assert_eq!(p.muscle, 0.5);
569        assert_eq!(p.age, 1.0);
570        assert_eq!(p.extra["x"], 0.0);
571        assert_eq!(p.extra["y"], 1.0);
572    }
573
574    #[test]
575    fn test_average_params() {
576        // Empty slice → None
577        assert!(average_params(&[]).is_none());
578
579        let a = make_a();
580        let b = make_b();
581        let avg = average_params(&[a.clone(), b.clone()]).expect("should succeed");
582        assert!((avg.height - 0.5).abs() < 1e-5);
583        assert!((avg.weight - 0.5).abs() < 1e-5);
584        assert!((avg.muscle - 0.5).abs() < 1e-5);
585        assert!((avg.age - 0.5).abs() < 1e-5);
586
587        // Single element → itself
588        let single = average_params(std::slice::from_ref(&a)).expect("should succeed");
589        assert!((single.height - a.height).abs() < 1e-5);
590    }
591}