Skip to main content

oxihuman_morph/
character_dna.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//! DNA-like compact binary encoding of morph parameters for sharing and
8//! seeding character variants.
9//!
10//! A short base64 or hex string encodes the full character morph state,
11//! enabling easy sharing, mutation, and crossover of character designs.
12
13use crate::params::ParamState;
14use std::collections::HashMap;
15
16// ---------------------------------------------------------------------------
17// Base64 alphabet (standard, with padding)
18// ---------------------------------------------------------------------------
19
20const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
21
22// ---------------------------------------------------------------------------
23// Data structures
24// ---------------------------------------------------------------------------
25
26/// A compact DNA representation of a character's parameters.
27#[derive(Debug, Clone, PartialEq)]
28pub struct CharacterDna {
29    /// Raw bytes encoding the parameters.
30    pub bytes: Vec<u8>,
31    /// Version tag for format compatibility.
32    pub version: u8,
33}
34
35/// Extended DNA that includes named extra params beyond the core 4.
36#[derive(Debug, Clone, PartialEq)]
37pub struct ExtendedDna {
38    pub core: CharacterDna,
39    pub extra_keys: Vec<String>,
40}
41
42// ---------------------------------------------------------------------------
43// LCG random number generator (no external crates)
44// ---------------------------------------------------------------------------
45
46fn lcg(state: &mut u64) -> u8 {
47    *state = state
48        .wrapping_mul(6_364_136_223_846_793_005)
49        .wrapping_add(1_442_695_040_888_963_407);
50    ((*state >> 33) & 0xFF) as u8
51}
52
53// ---------------------------------------------------------------------------
54// Encode / decode
55// ---------------------------------------------------------------------------
56
57/// Encode a [`ParamState`] into a compact [`CharacterDna`].
58///
59/// Format:
60/// - byte 0: version (1)
61/// - bytes 1–4: height, weight, muscle, age each as u8 (0=0.0, 255=1.0)
62/// - remaining: for each (key, value) in `params.extra`:
63///   key_len(u8) + key_bytes + value(u8)
64pub fn encode_dna(params: &ParamState) -> CharacterDna {
65    let mut bytes = vec![
66        1u8,
67        f32_to_u8(params.height),
68        f32_to_u8(params.weight),
69        f32_to_u8(params.muscle),
70        f32_to_u8(params.age),
71    ];
72
73    // extra params — sort for deterministic encoding
74    let mut extras: Vec<(&String, &f32)> = params.extra.iter().collect();
75    extras.sort_by_key(|(k, _)| k.as_str());
76
77    for (key, val) in extras {
78        let key_bytes = key.as_bytes();
79        // Clamp key length to u8::MAX
80        let key_len = key_bytes.len().min(255) as u8;
81        bytes.push(key_len);
82        bytes.extend_from_slice(&key_bytes[..key_len as usize]);
83        bytes.push(f32_to_u8(*val));
84    }
85
86    CharacterDna { bytes, version: 1 }
87}
88
89/// Decode a [`CharacterDna`] back into a [`ParamState`].
90///
91/// This is lossy (~0.004 precision due to u8 quantisation).
92pub fn decode_dna(dna: &CharacterDna) -> ParamState {
93    let bytes = &dna.bytes;
94
95    if bytes.is_empty() {
96        return ParamState::default();
97    }
98
99    // byte 0 is version (currently only 1 supported)
100    let mut cursor = 1usize;
101
102    let height = read_u8_f32(bytes, &mut cursor);
103    let weight = read_u8_f32(bytes, &mut cursor);
104    let muscle = read_u8_f32(bytes, &mut cursor);
105    let age = read_u8_f32(bytes, &mut cursor);
106
107    let mut extra = HashMap::new();
108
109    while cursor < bytes.len() {
110        // Read key length
111        let key_len = bytes[cursor] as usize;
112        cursor += 1;
113
114        if cursor + key_len + 1 > bytes.len() {
115            break; // truncated
116        }
117
118        let key_bytes = &bytes[cursor..cursor + key_len];
119        cursor += key_len;
120
121        let key = String::from_utf8_lossy(key_bytes).into_owned();
122        let val = u8_to_f32(bytes[cursor]);
123        cursor += 1;
124
125        extra.insert(key, val);
126    }
127
128    ParamState {
129        height,
130        weight,
131        muscle,
132        age,
133        extra,
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Hex serialisation
139// ---------------------------------------------------------------------------
140
141/// Encode a [`CharacterDna`] as a lowercase hex string.
142pub fn dna_to_hex(dna: &CharacterDna) -> String {
143    dna.bytes
144        .iter()
145        .map(|b| format!("{:02x}", b))
146        .collect::<String>()
147}
148
149/// Parse a hex string into a [`CharacterDna`].
150///
151/// Returns an error if the string contains non-hex characters or has odd length.
152pub fn dna_from_hex(hex: &str) -> anyhow::Result<CharacterDna> {
153    let hex = hex.trim();
154    if !hex.len().is_multiple_of(2) {
155        anyhow::bail!("hex string has odd length: {}", hex.len());
156    }
157
158    let mut bytes = Vec::with_capacity(hex.len() / 2);
159    let chars: Vec<char> = hex.chars().collect();
160    let mut i = 0;
161    while i < chars.len() {
162        let hi = hex_char(chars[i])?;
163        let lo = hex_char(chars[i + 1])?;
164        bytes.push((hi << 4) | lo);
165        i += 2;
166    }
167
168    let version = bytes.first().copied().unwrap_or(1);
169    Ok(CharacterDna { bytes, version })
170}
171
172// ---------------------------------------------------------------------------
173// Base64 serialisation (hand-written, no external crates)
174// ---------------------------------------------------------------------------
175
176/// Encode a [`CharacterDna`] as a standard base64 string (with `=` padding).
177pub fn dna_to_base64(dna: &CharacterDna) -> String {
178    let input = &dna.bytes;
179    let mut out = Vec::with_capacity(input.len().div_ceil(3) * 4);
180
181    let mut i = 0;
182    while i + 2 < input.len() {
183        let b0 = input[i] as u32;
184        let b1 = input[i + 1] as u32;
185        let b2 = input[i + 2] as u32;
186        let n = (b0 << 16) | (b1 << 8) | b2;
187        out.push(B64_CHARS[((n >> 18) & 0x3F) as usize]);
188        out.push(B64_CHARS[((n >> 12) & 0x3F) as usize]);
189        out.push(B64_CHARS[((n >> 6) & 0x3F) as usize]);
190        out.push(B64_CHARS[(n & 0x3F) as usize]);
191        i += 3;
192    }
193
194    let rem = input.len() - i;
195    if rem == 1 {
196        let b0 = input[i] as u32;
197        let n = b0 << 16;
198        out.push(B64_CHARS[((n >> 18) & 0x3F) as usize]);
199        out.push(B64_CHARS[((n >> 12) & 0x3F) as usize]);
200        out.push(b'=');
201        out.push(b'=');
202    } else if rem == 2 {
203        let b0 = input[i] as u32;
204        let b1 = input[i + 1] as u32;
205        let n = (b0 << 16) | (b1 << 8);
206        out.push(B64_CHARS[((n >> 18) & 0x3F) as usize]);
207        out.push(B64_CHARS[((n >> 12) & 0x3F) as usize]);
208        out.push(B64_CHARS[((n >> 6) & 0x3F) as usize]);
209        out.push(b'=');
210    }
211
212    // SAFETY: all bytes come from B64_CHARS which is valid ASCII
213    unsafe { String::from_utf8_unchecked(out) }
214}
215
216/// Decode a standard base64 string into a [`CharacterDna`].
217pub fn dna_from_base64(s: &str) -> anyhow::Result<CharacterDna> {
218    let s = s.trim();
219    if !s.len().is_multiple_of(4) {
220        anyhow::bail!(
221            "base64 string length must be a multiple of 4, got {}",
222            s.len()
223        );
224    }
225
226    let chars: Vec<u8> = s.bytes().collect();
227    let mut bytes: Vec<u8> = Vec::with_capacity(s.len() / 4 * 3);
228    let mut i = 0;
229
230    while i < chars.len() {
231        let c0 = b64_val(chars[i])?;
232        let c1 = b64_val(chars[i + 1])?;
233        let c2_raw = chars[i + 2];
234        let c3_raw = chars[i + 3];
235
236        bytes.push((c0 << 2) | (c1 >> 4));
237
238        if c2_raw != b'=' {
239            let c2 = b64_val(c2_raw)?;
240            bytes.push(((c1 & 0x0F) << 4) | (c2 >> 2));
241            if c3_raw != b'=' {
242                let c3 = b64_val(c3_raw)?;
243                bytes.push(((c2 & 0x03) << 6) | c3);
244            }
245        }
246
247        i += 4;
248    }
249
250    let version = bytes.first().copied().unwrap_or(1);
251    Ok(CharacterDna { bytes, version })
252}
253
254// ---------------------------------------------------------------------------
255// Distance / mutation / crossover
256// ---------------------------------------------------------------------------
257
258/// Hamming-like distance: sum of absolute differences over the minimum length
259/// of the two byte slices (ignoring the version byte).
260pub fn dna_distance(a: &CharacterDna, b: &CharacterDna) -> f32 {
261    let min_len = a.bytes.len().min(b.bytes.len());
262    let sum: u32 = a.bytes[..min_len]
263        .iter()
264        .zip(b.bytes[..min_len].iter())
265        .map(|(x, y)| (*x as i32 - *y as i32).unsigned_abs())
266        .sum();
267    sum as f32
268}
269
270/// Randomly mutate bytes in a [`CharacterDna`] based on `rate` (0.0–1.0).
271///
272/// Uses a simple LCG seeded by `seed`. The version byte (index 0) is never
273/// mutated.
274pub fn mutate_dna(dna: &CharacterDna, rate: f32, seed: u64) -> CharacterDna {
275    let rate = rate.clamp(0.0, 1.0);
276    let threshold = (rate * 255.0) as u8;
277    let mut state = seed;
278    let mut bytes = dna.bytes.clone();
279
280    for (i, byte) in bytes.iter_mut().enumerate() {
281        if i == 0 {
282            continue; // preserve version byte
283        }
284        let roll = lcg(&mut state);
285        if roll < threshold {
286            let delta = lcg(&mut state);
287            *byte = byte.wrapping_add(delta).wrapping_sub(128);
288        }
289    }
290
291    CharacterDna {
292        bytes,
293        version: dna.version,
294    }
295}
296
297/// Uniform byte-level crossover between two [`CharacterDna`] values.
298///
299/// For each byte position (starting after version), the LCG selects whether
300/// to take the byte from `a` or `b`. The version byte is taken from `a`.
301pub fn crossover_dna(a: &CharacterDna, b: &CharacterDna, seed: u64) -> CharacterDna {
302    let len = a.bytes.len().max(b.bytes.len());
303    let mut state = seed;
304    let mut bytes = Vec::with_capacity(len);
305
306    for i in 0..len {
307        if i == 0 {
308            bytes.push(a.version);
309            continue;
310        }
311        let pick_a = (lcg(&mut state) & 1) == 0;
312        let byte = if pick_a {
313            a.bytes.get(i).copied().unwrap_or(0)
314        } else {
315            b.bytes.get(i).copied().unwrap_or(0)
316        };
317        bytes.push(byte);
318    }
319
320    CharacterDna {
321        bytes,
322        version: a.version,
323    }
324}
325
326/// Decode a [`CharacterDna`] into a flat `HashMap<String, f32>` that always
327/// contains `"height"`, `"weight"`, `"muscle"`, and `"age"`.
328pub fn dna_to_params_map(dna: &CharacterDna) -> HashMap<String, f32> {
329    let params = decode_dna(dna);
330    let mut map = HashMap::new();
331    map.insert("height".to_string(), params.height);
332    map.insert("weight".to_string(), params.weight);
333    map.insert("muscle".to_string(), params.muscle);
334    map.insert("age".to_string(), params.age);
335    for (k, v) in params.extra {
336        map.insert(k, v);
337    }
338    map
339}
340
341// ---------------------------------------------------------------------------
342// Private helpers
343// ---------------------------------------------------------------------------
344
345#[inline]
346fn f32_to_u8(v: f32) -> u8 {
347    (v.clamp(0.0, 1.0) * 255.0).round() as u8
348}
349
350#[inline]
351fn u8_to_f32(b: u8) -> f32 {
352    b as f32 / 255.0
353}
354
355#[inline]
356fn read_u8_f32(bytes: &[u8], cursor: &mut usize) -> f32 {
357    if *cursor < bytes.len() {
358        let v = u8_to_f32(bytes[*cursor]);
359        *cursor += 1;
360        v
361    } else {
362        0.0
363    }
364}
365
366fn hex_char(c: char) -> anyhow::Result<u8> {
367    match c {
368        '0'..='9' => Ok(c as u8 - b'0'),
369        'a'..='f' => Ok(c as u8 - b'a' + 10),
370        'A'..='F' => Ok(c as u8 - b'A' + 10),
371        _ => anyhow::bail!("invalid hex character: {:?}", c),
372    }
373}
374
375fn b64_val(c: u8) -> anyhow::Result<u8> {
376    match c {
377        b'A'..=b'Z' => Ok(c - b'A'),
378        b'a'..=b'z' => Ok(c - b'a' + 26),
379        b'0'..=b'9' => Ok(c - b'0' + 52),
380        b'+' => Ok(62),
381        b'/' => Ok(63),
382        _ => anyhow::bail!("invalid base64 character: {:?}", c as char),
383    }
384}
385
386// ---------------------------------------------------------------------------
387// Tests
388// ---------------------------------------------------------------------------
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn make_params(height: f32, weight: f32, muscle: f32, age: f32) -> ParamState {
395        ParamState::new(height, weight, muscle, age)
396    }
397
398    fn make_params_with_extra(
399        height: f32,
400        weight: f32,
401        muscle: f32,
402        age: f32,
403        extra: &[(&str, f32)],
404    ) -> ParamState {
405        let mut p = make_params(height, weight, muscle, age);
406        for (k, v) in extra {
407            p.extra.insert(k.to_string(), *v);
408        }
409        p
410    }
411
412    // ------------------------------------------------------------------ 1
413    #[test]
414    fn test_encode_decode_roundtrip_core() {
415        let params = make_params(0.7, 0.4, 0.6, 0.3);
416        let dna = encode_dna(&params);
417        let decoded = decode_dna(&dna);
418        // Precision is ~1/255 ≈ 0.004
419        assert!(
420            (decoded.height - params.height).abs() < 0.005,
421            "height mismatch"
422        );
423        assert!(
424            (decoded.weight - params.weight).abs() < 0.005,
425            "weight mismatch"
426        );
427        assert!(
428            (decoded.muscle - params.muscle).abs() < 0.005,
429            "muscle mismatch"
430        );
431        assert!((decoded.age - params.age).abs() < 0.005, "age mismatch");
432    }
433
434    // ------------------------------------------------------------------ 2
435    #[test]
436    fn test_encode_decode_roundtrip_with_extra() {
437        let params = make_params_with_extra(
438            0.5,
439            0.5,
440            0.5,
441            0.5,
442            &[("nose_width", 0.8), ("jaw_size", 0.2)],
443        );
444        let dna = encode_dna(&params);
445        let decoded = decode_dna(&dna);
446        assert!((decoded.extra["nose_width"] - 0.8).abs() < 0.005);
447        assert!((decoded.extra["jaw_size"] - 0.2).abs() < 0.005);
448    }
449
450    // ------------------------------------------------------------------ 3
451    #[test]
452    fn test_version_field_is_one() {
453        let params = make_params(0.5, 0.5, 0.5, 0.5);
454        let dna = encode_dna(&params);
455        assert_eq!(dna.version, 1);
456        assert_eq!(dna.bytes[0], 1);
457    }
458
459    // ------------------------------------------------------------------ 4
460    #[test]
461    fn test_dna_to_hex_roundtrip() {
462        let params = make_params(0.2, 0.8, 0.4, 0.9);
463        let dna = encode_dna(&params);
464        let hex = dna_to_hex(&dna);
465        let dna2 = dna_from_hex(&hex).expect("should succeed");
466        assert_eq!(dna.bytes, dna2.bytes);
467    }
468
469    // ------------------------------------------------------------------ 5
470    #[test]
471    fn test_dna_from_hex_invalid_char() {
472        let result = dna_from_hex("01ZZ");
473        assert!(result.is_err());
474    }
475
476    // ------------------------------------------------------------------ 6
477    #[test]
478    fn test_dna_from_hex_odd_length() {
479        let result = dna_from_hex("01a");
480        assert!(result.is_err());
481    }
482
483    // ------------------------------------------------------------------ 7
484    #[test]
485    fn test_dna_to_base64_roundtrip() {
486        let params = make_params(0.3, 0.6, 0.9, 0.1);
487        let dna = encode_dna(&params);
488        let b64 = dna_to_base64(&dna);
489        let dna2 = dna_from_base64(&b64).expect("should succeed");
490        assert_eq!(dna.bytes, dna2.bytes);
491    }
492
493    // ------------------------------------------------------------------ 8
494    #[test]
495    fn test_dna_to_base64_padding() {
496        // Ensure padded output length is multiple of 4
497        let params = make_params(0.5, 0.5, 0.5, 0.5);
498        let dna = encode_dna(&params);
499        let b64 = dna_to_base64(&dna);
500        assert_eq!(b64.len() % 4, 0);
501    }
502
503    // ------------------------------------------------------------------ 9
504    #[test]
505    fn test_dna_from_base64_invalid_length() {
506        let result = dna_from_base64("ABC"); // not multiple of 4
507        assert!(result.is_err());
508    }
509
510    // ------------------------------------------------------------------ 10
511    #[test]
512    fn test_dna_distance_identical() {
513        let params = make_params(0.5, 0.5, 0.5, 0.5);
514        let dna = encode_dna(&params);
515        assert_eq!(dna_distance(&dna, &dna), 0.0);
516    }
517
518    // ------------------------------------------------------------------ 11
519    #[test]
520    fn test_dna_distance_different() {
521        let dna_a = encode_dna(&make_params(0.0, 0.0, 0.0, 0.0));
522        let dna_b = encode_dna(&make_params(1.0, 1.0, 1.0, 1.0));
523        let dist = dna_distance(&dna_a, &dna_b);
524        // Each of the 4 core bytes differs by 255, version byte is the same
525        assert!(dist > 0.0, "distance should be positive");
526        assert!(
527            (dist - 255.0 * 4.0).abs() < 1.0,
528            "expected ~1020, got {}",
529            dist
530        );
531    }
532
533    // ------------------------------------------------------------------ 12
534    #[test]
535    fn test_mutate_dna_deterministic() {
536        let params = make_params(0.5, 0.5, 0.5, 0.5);
537        let dna = encode_dna(&params);
538        let m1 = mutate_dna(&dna, 0.5, 42);
539        let m2 = mutate_dna(&dna, 0.5, 42);
540        assert_eq!(m1.bytes, m2.bytes);
541    }
542
543    // ------------------------------------------------------------------ 13
544    #[test]
545    fn test_mutate_dna_zero_rate_unchanged() {
546        let params = make_params(0.5, 0.5, 0.5, 0.5);
547        let dna = encode_dna(&params);
548        let mutated = mutate_dna(&dna, 0.0, 99);
549        assert_eq!(dna.bytes, mutated.bytes);
550    }
551
552    // ------------------------------------------------------------------ 14
553    #[test]
554    fn test_mutate_dna_preserves_version() {
555        let params = make_params(0.4, 0.6, 0.3, 0.7);
556        let dna = encode_dna(&params);
557        let mutated = mutate_dna(&dna, 1.0, 12345);
558        assert_eq!(mutated.version, 1);
559        assert_eq!(mutated.bytes[0], 1);
560    }
561
562    // ------------------------------------------------------------------ 15
563    #[test]
564    fn test_crossover_dna_deterministic() {
565        let dna_a = encode_dna(&make_params(0.1, 0.2, 0.3, 0.4));
566        let dna_b = encode_dna(&make_params(0.9, 0.8, 0.7, 0.6));
567        let c1 = crossover_dna(&dna_a, &dna_b, 7);
568        let c2 = crossover_dna(&dna_a, &dna_b, 7);
569        assert_eq!(c1.bytes, c2.bytes);
570    }
571
572    // ------------------------------------------------------------------ 16
573    #[test]
574    fn test_crossover_dna_bytes_come_from_parents() {
575        let dna_a = encode_dna(&make_params(0.0, 0.0, 0.0, 0.0));
576        let dna_b = encode_dna(&make_params(1.0, 1.0, 1.0, 1.0));
577        let child = crossover_dna(&dna_a, &dna_b, 99);
578        for (i, &byte) in child.bytes.iter().enumerate() {
579            if i == 0 {
580                assert_eq!(byte, 1, "version byte must be 1");
581                continue;
582            }
583            let a_byte = dna_a.bytes.get(i).copied().unwrap_or(0);
584            let b_byte = dna_b.bytes.get(i).copied().unwrap_or(0);
585            assert!(
586                byte == a_byte || byte == b_byte,
587                "byte {} = {} must come from a ({}) or b ({})",
588                i,
589                byte,
590                a_byte,
591                b_byte
592            );
593        }
594    }
595
596    // ------------------------------------------------------------------ 17
597    #[test]
598    fn test_dna_to_params_map_core_keys_present() {
599        let dna = encode_dna(&make_params(0.25, 0.75, 0.5, 0.0));
600        let map = dna_to_params_map(&dna);
601        assert!(map.contains_key("height"));
602        assert!(map.contains_key("weight"));
603        assert!(map.contains_key("muscle"));
604        assert!(map.contains_key("age"));
605    }
606
607    // ------------------------------------------------------------------ 18
608    #[test]
609    fn test_dna_to_params_map_values_accurate() {
610        let dna = encode_dna(&make_params(0.25, 0.75, 0.5, 1.0));
611        let map = dna_to_params_map(&dna);
612        assert!((map["height"] - 0.25).abs() < 0.005);
613        assert!((map["weight"] - 0.75).abs() < 0.005);
614        assert!((map["muscle"] - 0.5).abs() < 0.005);
615        assert!((map["age"] - 1.0).abs() < 0.005);
616    }
617
618    // ------------------------------------------------------------------ 19
619    #[test]
620    fn test_empty_extra_params() {
621        let params = make_params(0.5, 0.5, 0.5, 0.5);
622        let dna = encode_dna(&params);
623        // version(1) + 4 core bytes = 5 bytes total
624        assert_eq!(dna.bytes.len(), 5);
625        let decoded = decode_dna(&dna);
626        assert!(decoded.extra.is_empty());
627    }
628
629    // ------------------------------------------------------------------ 20
630    #[test]
631    fn test_hex_output_is_lowercase() {
632        let params = make_params(0.5, 0.5, 0.5, 0.5);
633        let dna = encode_dna(&params);
634        let hex = dna_to_hex(&dna);
635        assert_eq!(hex, hex.to_lowercase());
636    }
637
638    // ------------------------------------------------------------------ 21
639    #[test]
640    fn test_hex_length_matches_bytes() {
641        let params = make_params_with_extra(0.1, 0.2, 0.3, 0.4, &[("x", 0.5), ("y", 0.6)]);
642        let dna = encode_dna(&params);
643        let hex = dna_to_hex(&dna);
644        assert_eq!(hex.len(), dna.bytes.len() * 2);
645    }
646
647    // ------------------------------------------------------------------ 22
648    #[test]
649    fn test_dna_boundary_values_zero() {
650        let params = make_params(0.0, 0.0, 0.0, 0.0);
651        let dna = encode_dna(&params);
652        let decoded = decode_dna(&dna);
653        assert!(decoded.height.abs() < 0.005);
654        assert!(decoded.weight.abs() < 0.005);
655        assert!(decoded.muscle.abs() < 0.005);
656        assert!(decoded.age.abs() < 0.005);
657    }
658
659    // ------------------------------------------------------------------ 23
660    #[test]
661    fn test_dna_boundary_values_one() {
662        let params = make_params(1.0, 1.0, 1.0, 1.0);
663        let dna = encode_dna(&params);
664        let decoded = decode_dna(&dna);
665        assert!((decoded.height - 1.0).abs() < 0.005);
666        assert!((decoded.weight - 1.0).abs() < 0.005);
667        assert!((decoded.muscle - 1.0).abs() < 0.005);
668        assert!((decoded.age - 1.0).abs() < 0.005);
669    }
670
671    // ------------------------------------------------------------------ 24
672    #[test]
673    fn test_extended_dna_struct() {
674        let dna = encode_dna(&make_params(0.5, 0.5, 0.5, 0.5));
675        let ext = ExtendedDna {
676            core: dna.clone(),
677            extra_keys: vec!["nose_width".to_string(), "jaw_size".to_string()],
678        };
679        assert_eq!(ext.core, dna);
680        assert_eq!(ext.extra_keys.len(), 2);
681    }
682
683    // ------------------------------------------------------------------ 25
684    #[test]
685    fn test_lcg_produces_deterministic_sequence() {
686        let mut s1 = 12345u64;
687        let mut s2 = 12345u64;
688        let seq1: Vec<u8> = (0..10).map(|_| lcg(&mut s1)).collect();
689        let seq2: Vec<u8> = (0..10).map(|_| lcg(&mut s2)).collect();
690        assert_eq!(seq1, seq2);
691    }
692
693    // ------------------------------------------------------------------ 26 (bonus)
694    #[test]
695    fn test_extra_keys_sorted_deterministically() {
696        // Same extra params in different insertion orders should encode identically
697        let mut p1 = make_params(0.5, 0.5, 0.5, 0.5);
698        p1.extra.insert("z_key".to_string(), 0.3);
699        p1.extra.insert("a_key".to_string(), 0.7);
700
701        let mut p2 = make_params(0.5, 0.5, 0.5, 0.5);
702        p2.extra.insert("a_key".to_string(), 0.7);
703        p2.extra.insert("z_key".to_string(), 0.3);
704
705        let d1 = encode_dna(&p1);
706        let d2 = encode_dna(&p2);
707        assert_eq!(d1.bytes, d2.bytes);
708    }
709}