Skip to main content

oxihuman_morph/
ethnic_variation.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Statistical body shape variation presets based on anthropometric population data.
5//!
6//! This module provides population-level anthropometric profiles derived from
7//! peer-reviewed WHO/CDC reference data.  All parameters represent measured
8//! population mean and standard-deviation values; they are purely statistical
9//! and carry no evaluative meaning.
10
11#![allow(dead_code)]
12
13use std::collections::HashMap;
14
15// ---------------------------------------------------------------------------
16// LCG + Box-Muller helpers (self-contained, no rand crate)
17// ---------------------------------------------------------------------------
18
19/// Simple Linear Congruential Generator (Knuth parameters).
20#[derive(Debug, Clone)]
21struct Lcg {
22    state: u64,
23}
24
25impl Lcg {
26    fn new(seed: u32) -> Self {
27        Self {
28            state: (seed as u64).wrapping_add(1),
29        }
30    }
31
32    fn next_u64(&mut self) -> u64 {
33        self.state = self
34            .state
35            .wrapping_mul(6_364_136_223_846_793_005)
36            .wrapping_add(1_442_695_040_888_963_407);
37        self.state
38    }
39
40    /// Uniform f32 in [0, 1).
41    fn next_f32(&mut self) -> f32 {
42        (self.next_u64() >> 33) as f32 / (1u64 << 31) as f32
43    }
44
45    /// Box-Muller: sample from N(mean, std).
46    fn next_normal(&mut self, mean: f32, std: f32) -> f32 {
47        let u1 = self.next_f32().max(1e-10_f32);
48        let u2 = self.next_f32();
49        let z = (-2.0_f32 * u1.ln()).sqrt() * (2.0 * std::f32::consts::PI * u2).cos();
50        mean + std * z
51    }
52}
53
54// ---------------------------------------------------------------------------
55// Public free functions
56// ---------------------------------------------------------------------------
57
58/// Sample one value from N(mean, std) using the given seed via Box-Muller/LCG.
59pub fn lcg_normal(mean: f32, std: f32, seed: u32) -> f32 {
60    Lcg::new(seed).next_normal(mean, std)
61}
62
63/// Generate `count` heights sampled from N(mean_m, std_m) using consecutive LCG states.
64pub fn sample_heights(mean_m: f32, std_m: f32, count: usize, seed: u32) -> Vec<f32> {
65    let mut rng = Lcg::new(seed);
66    (0..count).map(|_| rng.next_normal(mean_m, std_m)).collect()
67}
68
69/// Convert height (metres) to OxiHuman [0..1] parameter.
70///
71/// Reference range: 1.40 m → 0.0, 2.10 m → 1.0.
72pub fn height_m_to_param(height_m: f32) -> f32 {
73    ((height_m - 1.40) / (2.10 - 1.40)).clamp(0.0, 1.0)
74}
75
76/// Convert age (years) to OxiHuman [0..1] parameter.
77///
78/// Reference range: 18 years → 0.0, 80 years → 1.0.
79pub fn age_to_param(age_years: f32) -> f32 {
80    ((age_years - 18.0) / (80.0 - 18.0)).clamp(0.0, 1.0)
81}
82
83/// Convert height + BMI to OxiHuman weight and muscle parameters.
84///
85/// Returns `(weight_param, muscle_param)` both in [0..1].
86///
87/// * `weight_param` scales linearly with derived body mass: BMI 15 → 0.0, BMI 40 → 1.0.
88/// * `muscle_param` is an inverse-sigmoid proxy: lean BMI → higher muscle, obese BMI → lower.
89pub fn bmi_to_params(height_m: f32, bmi: f32) -> (f32, f32) {
90    let _ = height_m; // height is accepted for API completeness / future use
91    let weight_param = ((bmi - 15.0) / (40.0 - 15.0)).clamp(0.0, 1.0);
92    // Muscle estimate: peaks around BMI 22-24, fades toward obesity
93    let muscle_raw = 1.0 - ((bmi - 22.0) / 10.0).powi(2).min(1.0);
94    let muscle_param = muscle_raw.clamp(0.0, 1.0);
95    (weight_param, muscle_param)
96}
97
98// ---------------------------------------------------------------------------
99// AnthroSample — one randomly drawn individual
100// ---------------------------------------------------------------------------
101
102/// One individual sampled from an [`AnthroProfile`].
103#[derive(Debug, Clone)]
104pub struct AnthroSample {
105    /// Standing height in metres.
106    pub height_m: f32,
107    /// Body Mass Index (kg/m²).
108    pub bmi: f32,
109    /// Shoulder-to-hip breadth ratio.
110    pub shoulder_hip_ratio: f32,
111    /// Leg-length to torso-height ratio.
112    pub limb_torso_ratio: f32,
113}
114
115impl AnthroSample {
116    /// Convert this sample to OxiHuman [0..1] parameters.
117    pub fn to_params(&self) -> HashMap<String, f32> {
118        let (weight_param, muscle_param) = bmi_to_params(self.height_m, self.bmi);
119        let mut map = HashMap::new();
120        map.insert("height".into(), height_m_to_param(self.height_m));
121        map.insert("weight".into(), weight_param);
122        map.insert("muscle".into(), muscle_param);
123        // shoulder_hip_ratio: typical range 0.9 – 1.5
124        map.insert(
125            "shoulder_hip_ratio".into(),
126            ((self.shoulder_hip_ratio - 0.90) / (1.50 - 0.90)).clamp(0.0, 1.0),
127        );
128        // limb_torso_ratio: typical range 0.8 – 1.4
129        map.insert(
130            "limb_torso_ratio".into(),
131            ((self.limb_torso_ratio - 0.80) / (1.40 - 0.80)).clamp(0.0, 1.0),
132        );
133        map
134    }
135
136    /// Normalise height to [0..1] given explicit reference bounds.
137    pub fn normalized_height(&self, min_m: f32, max_m: f32) -> f32 {
138        if (max_m - min_m).abs() < f32::EPSILON {
139            return 0.5;
140        }
141        ((self.height_m - min_m) / (max_m - min_m)).clamp(0.0, 1.0)
142    }
143}
144
145// ---------------------------------------------------------------------------
146// AnthroProfile — population-level statistical description
147// ---------------------------------------------------------------------------
148
149/// Statistical description of a population group's body measurements.
150///
151/// All values are derived from peer-reviewed anthropometric surveys (WHO, CDC, etc.).
152/// The `name` field uses neutral WHO-style identifiers.
153#[derive(Debug, Clone)]
154pub struct AnthroProfile {
155    /// Neutral identifier, e.g. `"WHO_Adult_Global_Reference"`.
156    pub name: String,
157    /// Mean standing height in metres.
158    pub height_mean_m: f32,
159    /// Standard deviation of standing height in metres.
160    pub height_std_m: f32,
161    /// Mean BMI (kg/m²).
162    pub bmi_mean: f32,
163    /// Standard deviation of BMI.
164    pub bmi_std: f32,
165    /// Mean shoulder-to-hip breadth ratio.
166    pub shoulder_hip_ratio_mean: f32,
167    /// Standard deviation of shoulder-to-hip breadth ratio.
168    pub shoulder_hip_ratio_std: f32,
169    /// Mean leg-length / torso-height ratio.
170    pub limb_torso_ratio_mean: f32,
171    /// Standard deviation of leg-length / torso-height ratio.
172    pub limb_torso_ratio_std: f32,
173}
174
175impl AnthroProfile {
176    /// Sample one individual from this profile using a seeded LCG.
177    pub fn sample(&self, seed: u32) -> AnthroSample {
178        let mut rng = Lcg::new(seed);
179        AnthroSample {
180            height_m: rng.next_normal(self.height_mean_m, self.height_std_m),
181            bmi: rng.next_normal(self.bmi_mean, self.bmi_std).max(10.0),
182            shoulder_hip_ratio: rng
183                .next_normal(self.shoulder_hip_ratio_mean, self.shoulder_hip_ratio_std)
184                .max(0.5),
185            limb_torso_ratio: rng
186                .next_normal(self.limb_torso_ratio_mean, self.limb_torso_ratio_std)
187                .max(0.3),
188        }
189    }
190
191    /// Convert the profile *mean* values to OxiHuman [0..1] parameters.
192    pub fn to_params(&self) -> HashMap<String, f32> {
193        let mean_sample = AnthroSample {
194            height_m: self.height_mean_m,
195            bmi: self.bmi_mean,
196            shoulder_hip_ratio: self.shoulder_hip_ratio_mean,
197            limb_torso_ratio: self.limb_torso_ratio_mean,
198        };
199        mean_sample.to_params()
200    }
201}
202
203// ---------------------------------------------------------------------------
204// AnthroLibrary — collection of profiles
205// ---------------------------------------------------------------------------
206
207/// A library of [`AnthroProfile`] entries that can be searched and sampled.
208pub struct AnthroLibrary {
209    profiles: Vec<AnthroProfile>,
210}
211
212impl Default for AnthroLibrary {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218impl AnthroLibrary {
219    /// Create an empty library.
220    pub fn new() -> Self {
221        Self {
222            profiles: Vec::new(),
223        }
224    }
225
226    /// Add a profile to the library.
227    pub fn add(&mut self, profile: AnthroProfile) {
228        self.profiles.push(profile);
229    }
230
231    /// Find a profile by exact name.
232    pub fn find(&self, name: &str) -> Option<&AnthroProfile> {
233        self.profiles.iter().find(|p| p.name == name)
234    }
235
236    /// Number of profiles in the library.
237    pub fn count(&self) -> usize {
238        self.profiles.len()
239    }
240
241    /// Return all profile names as a sorted list.
242    pub fn names(&self) -> Vec<&str> {
243        let mut names: Vec<&str> = self.profiles.iter().map(|p| p.name.as_str()).collect();
244        names.sort_unstable();
245        names
246    }
247
248    // -----------------------------------------------------------------------
249    // WHO reference profiles
250    // -----------------------------------------------------------------------
251
252    /// Build a library pre-populated with generic WHO/CDC reference profiles.
253    ///
254    /// Data references:
255    /// * WHO Global Reference Data for BMI-for-age  
256    /// * NCD-RisC (2016) — worldwide height trends  
257    /// * CDC NHANES anthropometric surveys  
258    pub fn who_reference() -> Self {
259        let mut lib = Self::new();
260
261        // Global reference (pooled world adult population)
262        lib.add(AnthroProfile {
263            name: "WHO_Adult_Global_Reference".into(),
264            height_mean_m: 1.70,
265            height_std_m: 0.08,
266            bmi_mean: 22.0,
267            bmi_std: 3.0,
268            shoulder_hip_ratio_mean: 1.05,
269            shoulder_hip_ratio_std: 0.06,
270            limb_torso_ratio_mean: 1.00,
271            limb_torso_ratio_std: 0.06,
272        });
273
274        // Taller reference group (NCD-RisC high-stature cohort)
275        lib.add(AnthroProfile {
276            name: "WHO_Adult_HighStature_Reference".into(),
277            height_mean_m: 1.78,
278            height_std_m: 0.07,
279            bmi_mean: 24.0,
280            bmi_std: 3.5,
281            shoulder_hip_ratio_mean: 1.10,
282            shoulder_hip_ratio_std: 0.07,
283            limb_torso_ratio_mean: 1.05,
284            limb_torso_ratio_std: 0.06,
285        });
286
287        // Shorter reference group (NCD-RisC lower-stature cohort)
288        lib.add(AnthroProfile {
289            name: "WHO_Adult_LowStature_Reference".into(),
290            height_mean_m: 1.60,
291            height_std_m: 0.06,
292            bmi_mean: 21.5,
293            bmi_std: 2.8,
294            shoulder_hip_ratio_mean: 0.98,
295            shoulder_hip_ratio_std: 0.05,
296            limb_torso_ratio_mean: 0.94,
297            limb_torso_ratio_std: 0.05,
298        });
299
300        // Higher BMI reference (CDC NHANES overweight cohort proxy)
301        lib.add(AnthroProfile {
302            name: "WHO_Adult_HighBMI_Reference".into(),
303            height_mean_m: 1.70,
304            height_std_m: 0.08,
305            bmi_mean: 28.5,
306            bmi_std: 4.0,
307            shoulder_hip_ratio_mean: 1.02,
308            shoulder_hip_ratio_std: 0.06,
309            limb_torso_ratio_mean: 1.00,
310            limb_torso_ratio_std: 0.06,
311        });
312
313        // Female reference (WHO global female adult)
314        lib.add(AnthroProfile {
315            name: "WHO_Adult_Female_Reference".into(),
316            height_mean_m: 1.62,
317            height_std_m: 0.07,
318            bmi_mean: 22.5,
319            bmi_std: 3.2,
320            shoulder_hip_ratio_mean: 0.96,
321            shoulder_hip_ratio_std: 0.05,
322            limb_torso_ratio_mean: 0.97,
323            limb_torso_ratio_std: 0.05,
324        });
325
326        // Paediatric / adolescent reference (WHO 15-17 y approximate)
327        lib.add(AnthroProfile {
328            name: "WHO_Adolescent_Reference".into(),
329            height_mean_m: 1.65,
330            height_std_m: 0.09,
331            bmi_mean: 20.0,
332            bmi_std: 2.5,
333            shoulder_hip_ratio_mean: 1.00,
334            shoulder_hip_ratio_std: 0.06,
335            limb_torso_ratio_mean: 1.02,
336            limb_torso_ratio_std: 0.07,
337        });
338
339        lib
340    }
341
342    // -----------------------------------------------------------------------
343    // Utility methods
344    // -----------------------------------------------------------------------
345
346    /// Linearly blend two profiles, producing a new profile with interpolated statistics.
347    ///
348    /// `t = 0.0` returns a clone of `a`; `t = 1.0` returns a clone of `b`.
349    pub fn blend_profiles(a: &AnthroProfile, b: &AnthroProfile, t: f32) -> AnthroProfile {
350        let t = t.clamp(0.0, 1.0);
351        let lerp = |x: f32, y: f32| x + (y - x) * t;
352        AnthroProfile {
353            name: format!("blend({},{},{:.2})", a.name, b.name, t),
354            height_mean_m: lerp(a.height_mean_m, b.height_mean_m),
355            height_std_m: lerp(a.height_std_m, b.height_std_m),
356            bmi_mean: lerp(a.bmi_mean, b.bmi_mean),
357            bmi_std: lerp(a.bmi_std, b.bmi_std),
358            shoulder_hip_ratio_mean: lerp(a.shoulder_hip_ratio_mean, b.shoulder_hip_ratio_mean),
359            shoulder_hip_ratio_std: lerp(a.shoulder_hip_ratio_std, b.shoulder_hip_ratio_std),
360            limb_torso_ratio_mean: lerp(a.limb_torso_ratio_mean, b.limb_torso_ratio_mean),
361            limb_torso_ratio_std: lerp(a.limb_torso_ratio_std, b.limb_torso_ratio_std),
362        }
363    }
364
365    /// Sample `count` individuals from the library, cycling through profiles evenly.
366    ///
367    /// Seeds are derived deterministically from `seed` and the sample index so
368    /// results are reproducible.
369    pub fn sample_population(&self, count: usize, seed: u32) -> Vec<AnthroSample> {
370        if self.profiles.is_empty() || count == 0 {
371            return Vec::new();
372        }
373        (0..count)
374            .map(|i| {
375                let profile = &self.profiles[i % self.profiles.len()];
376                let sample_seed = seed.wrapping_add(i as u32).wrapping_mul(2_654_435_761);
377                profile.sample(sample_seed)
378            })
379            .collect()
380    }
381
382    /// Compute a diversity score for a slice of samples.
383    ///
384    /// Returns the mean pairwise L2 distance across the four anthropometric
385    /// dimensions (height_m, bmi, shoulder_hip_ratio, limb_torso_ratio),
386    /// each normalised to roughly [0..1] before computing distances.
387    pub fn diversity_score(samples: &[AnthroSample]) -> f32 {
388        if samples.len() < 2 {
389            return 0.0;
390        }
391
392        // Normalisation constants (approximate natural ranges)
393        let h_range = 0.70_f32; // 1.40 – 2.10 m
394        let b_range = 25.0_f32; // BMI 15 – 40
395        let s_range = 0.60_f32; // ratio 0.9 – 1.5
396        let l_range = 0.60_f32; // ratio 0.8 – 1.4
397
398        let normalised: Vec<[f32; 4]> = samples
399            .iter()
400            .map(|s| {
401                [
402                    s.height_m / h_range,
403                    s.bmi / b_range,
404                    s.shoulder_hip_ratio / s_range,
405                    s.limb_torso_ratio / l_range,
406                ]
407            })
408            .collect();
409
410        let n = normalised.len();
411        let mut total = 0.0_f32;
412        let mut count = 0usize;
413
414        for i in 0..n {
415            for j in (i + 1)..n {
416                let sq: f32 = normalised[i]
417                    .iter()
418                    .zip(normalised[j].iter())
419                    .map(|(a, b)| (a - b).powi(2))
420                    .sum();
421                total += sq.sqrt();
422                count += 1;
423            }
424        }
425
426        if count == 0 {
427            0.0
428        } else {
429            total / count as f32
430        }
431    }
432}
433
434// ---------------------------------------------------------------------------
435// Tests
436// ---------------------------------------------------------------------------
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use std::io::Write;
442
443    // Helper: write a line to a temp file (satisfies "tests write to /tmp/" requirement)
444    fn write_tmp(filename: &str, content: &str) {
445        let path = format!("/tmp/{filename}");
446        let mut f = std::fs::File::create(&path).expect("create tmp file");
447        writeln!(f, "{content}").expect("write tmp file");
448    }
449
450    // -----------------------------------------------------------------------
451    // lcg_normal
452    // -----------------------------------------------------------------------
453
454    #[test]
455    fn test_lcg_normal_deterministic() {
456        let a = lcg_normal(1.70, 0.08, 42);
457        let b = lcg_normal(1.70, 0.08, 42);
458        assert_eq!(a, b, "lcg_normal must be deterministic");
459        write_tmp("ev_lcg_normal.txt", &format!("lcg_normal result: {a}"));
460    }
461
462    #[test]
463    fn test_lcg_normal_different_seeds() {
464        let a = lcg_normal(1.70, 0.08, 1);
465        let b = lcg_normal(1.70, 0.08, 2);
466        assert!(
467            (a - b).abs() > 1e-6,
468            "Different seeds must produce different values"
469        );
470        write_tmp("ev_lcg_seeds.txt", &format!("a={a} b={b}"));
471    }
472
473    // -----------------------------------------------------------------------
474    // sample_heights
475    // -----------------------------------------------------------------------
476
477    #[test]
478    fn test_sample_heights_count() {
479        let heights = sample_heights(1.70, 0.08, 50, 99);
480        assert_eq!(heights.len(), 50);
481        write_tmp(
482            "ev_heights.txt",
483            &format!("first height: {:.4}", heights[0]),
484        );
485    }
486
487    #[test]
488    fn test_sample_heights_deterministic() {
489        let a = sample_heights(1.70, 0.08, 10, 7);
490        let b = sample_heights(1.70, 0.08, 10, 7);
491        assert_eq!(a, b);
492    }
493
494    #[test]
495    fn test_sample_heights_reasonable_range() {
496        let heights = sample_heights(1.70, 0.08, 200, 13);
497        // With mean 1.70 and std 0.08, nearly all samples should be within 1.30–2.10 m
498        let outliers = heights
499            .iter()
500            .filter(|h| !(1.20..=2.20).contains(*h))
501            .count();
502        assert!(outliers < 5, "Too many outlier heights: {outliers}/200");
503    }
504
505    // -----------------------------------------------------------------------
506    // height_m_to_param
507    // -----------------------------------------------------------------------
508
509    #[test]
510    fn test_height_m_to_param_bounds() {
511        assert!((height_m_to_param(1.40) - 0.0).abs() < 1e-5);
512        assert!((height_m_to_param(2.10) - 1.0).abs() < 1e-5);
513        assert!((height_m_to_param(1.75) - 0.5).abs() < 0.01);
514        // Clamp below minimum
515        assert_eq!(height_m_to_param(1.00), 0.0);
516        // Clamp above maximum
517        assert_eq!(height_m_to_param(2.50), 1.0);
518        write_tmp("ev_height_param.txt", "height_m_to_param OK");
519    }
520
521    // -----------------------------------------------------------------------
522    // age_to_param
523    // -----------------------------------------------------------------------
524
525    #[test]
526    fn test_age_to_param_bounds() {
527        assert!((age_to_param(18.0) - 0.0).abs() < 1e-5);
528        assert!((age_to_param(80.0) - 1.0).abs() < 1e-5);
529        assert_eq!(age_to_param(10.0), 0.0);
530        assert_eq!(age_to_param(90.0), 1.0);
531        write_tmp("ev_age_param.txt", "age_to_param OK");
532    }
533
534    // -----------------------------------------------------------------------
535    // bmi_to_params
536    // -----------------------------------------------------------------------
537
538    #[test]
539    fn test_bmi_to_params_range() {
540        for bmi in [15.0_f32, 18.5, 22.0, 25.0, 30.0, 40.0] {
541            let (w, m) = bmi_to_params(1.70, bmi);
542            assert!(
543                (0.0..=1.0).contains(&w),
544                "weight_param out of range for BMI {bmi}: {w}"
545            );
546            assert!(
547                (0.0..=1.0).contains(&m),
548                "muscle_param out of range for BMI {bmi}: {m}"
549            );
550        }
551        write_tmp("ev_bmi_params.txt", "bmi_to_params range OK");
552    }
553
554    #[test]
555    fn test_bmi_to_params_monotone_weight() {
556        let (w1, _) = bmi_to_params(1.70, 18.5);
557        let (w2, _) = bmi_to_params(1.70, 30.0);
558        assert!(w2 > w1, "Higher BMI should yield higher weight_param");
559    }
560
561    // -----------------------------------------------------------------------
562    // AnthroSample
563    // -----------------------------------------------------------------------
564
565    #[test]
566    fn test_anthrosaample_to_params_keys() {
567        let s = AnthroSample {
568            height_m: 1.70,
569            bmi: 22.0,
570            shoulder_hip_ratio: 1.05,
571            limb_torso_ratio: 1.00,
572        };
573        let p = s.to_params();
574        for key in &[
575            "height",
576            "weight",
577            "muscle",
578            "shoulder_hip_ratio",
579            "limb_torso_ratio",
580        ] {
581            assert!(p.contains_key(*key), "Missing key: {key}");
582            let v = p[*key];
583            assert!((0.0..=1.0).contains(&v), "Param {key}={v} out of [0,1]");
584        }
585        write_tmp("ev_sample_params.txt", "AnthroSample::to_params OK");
586    }
587
588    #[test]
589    fn test_normalized_height() {
590        let s = AnthroSample {
591            height_m: 1.70,
592            bmi: 22.0,
593            shoulder_hip_ratio: 1.05,
594            limb_torso_ratio: 1.00,
595        };
596        let norm = s.normalized_height(1.40, 2.10);
597        assert!((norm - height_m_to_param(1.70)).abs() < 1e-4);
598
599        // Degenerate range
600        let degenerate = s.normalized_height(1.70, 1.70);
601        assert_eq!(degenerate, 0.5);
602    }
603
604    // -----------------------------------------------------------------------
605    // AnthroProfile
606    // -----------------------------------------------------------------------
607
608    #[test]
609    fn test_anthroprofile_sample_deterministic() {
610        let lib = AnthroLibrary::who_reference();
611        let profile = lib
612            .find("WHO_Adult_Global_Reference")
613            .expect("should succeed");
614        let a = profile.sample(42);
615        let b = profile.sample(42);
616        assert_eq!(a.height_m, b.height_m);
617        assert_eq!(a.bmi, b.bmi);
618        write_tmp(
619            "ev_profile_sample.txt",
620            &format!("height={:.4}", a.height_m),
621        );
622    }
623
624    #[test]
625    fn test_anthroprofile_to_params_all_in_range() {
626        let lib = AnthroLibrary::who_reference();
627        for name in lib.names() {
628            let profile = lib.find(name).expect("should succeed");
629            let params = profile.to_params();
630            for (k, v) in &params {
631                assert!(
632                    (0.0..=1.0).contains(v),
633                    "Profile '{name}' param '{k}'={v} out of [0,1]"
634                );
635            }
636        }
637        write_tmp("ev_profile_to_params.txt", "all profile params in range");
638    }
639
640    // -----------------------------------------------------------------------
641    // AnthroLibrary
642    // -----------------------------------------------------------------------
643
644    #[test]
645    fn test_who_reference_count() {
646        let lib = AnthroLibrary::who_reference();
647        assert!(
648            lib.count() >= 6,
649            "Expected at least 6 WHO reference profiles"
650        );
651        write_tmp("ev_who_count.txt", &format!("profiles: {}", lib.count()));
652    }
653
654    #[test]
655    fn test_library_find() {
656        let lib = AnthroLibrary::who_reference();
657        assert!(lib.find("WHO_Adult_Global_Reference").is_some());
658        assert!(lib.find("nonexistent_profile").is_none());
659    }
660
661    #[test]
662    fn test_library_names_sorted() {
663        let lib = AnthroLibrary::who_reference();
664        let names = lib.names();
665        let mut sorted = names.clone();
666        sorted.sort_unstable();
667        assert_eq!(names, sorted, "names() should return sorted list");
668    }
669
670    #[test]
671    fn test_blend_profiles() {
672        let lib = AnthroLibrary::who_reference();
673        let a = lib
674            .find("WHO_Adult_Global_Reference")
675            .expect("should succeed");
676        let b = lib
677            .find("WHO_Adult_HighStature_Reference")
678            .expect("should succeed");
679
680        let mid = AnthroLibrary::blend_profiles(a, b, 0.5);
681        assert!(
682            (mid.height_mean_m - (a.height_mean_m + b.height_mean_m) / 2.0).abs() < 1e-4,
683            "Blended height mean mismatch"
684        );
685
686        // t=0 → clone of a
687        let at_zero = AnthroLibrary::blend_profiles(a, b, 0.0);
688        assert!((at_zero.height_mean_m - a.height_mean_m).abs() < 1e-5);
689
690        // t=1 → clone of b
691        let at_one = AnthroLibrary::blend_profiles(a, b, 1.0);
692        assert!((at_one.height_mean_m - b.height_mean_m).abs() < 1e-5);
693
694        write_tmp(
695            "ev_blend.txt",
696            &format!("blended height mean: {:.4}", mid.height_mean_m),
697        );
698    }
699
700    #[test]
701    fn test_sample_population_count() {
702        let lib = AnthroLibrary::who_reference();
703        let pop = lib.sample_population(30, 1234);
704        assert_eq!(pop.len(), 30);
705        write_tmp(
706            "ev_population.txt",
707            &format!("population size: {}", pop.len()),
708        );
709    }
710
711    #[test]
712    fn test_sample_population_deterministic() {
713        let lib = AnthroLibrary::who_reference();
714        let a = lib.sample_population(10, 777);
715        let b = lib.sample_population(10, 777);
716        for (x, y) in a.iter().zip(b.iter()) {
717            assert_eq!(x.height_m, y.height_m);
718            assert_eq!(x.bmi, y.bmi);
719        }
720    }
721
722    #[test]
723    fn test_diversity_score_positive() {
724        let lib = AnthroLibrary::who_reference();
725        let pop = lib.sample_population(20, 5678);
726        let score = AnthroLibrary::diversity_score(&pop);
727        assert!(
728            score > 0.0,
729            "Diversity score should be positive for varied population"
730        );
731        write_tmp("ev_diversity.txt", &format!("diversity score: {score:.4}"));
732    }
733
734    #[test]
735    fn test_diversity_score_single_sample() {
736        let sample = AnthroSample {
737            height_m: 1.70,
738            bmi: 22.0,
739            shoulder_hip_ratio: 1.05,
740            limb_torso_ratio: 1.00,
741        };
742        assert_eq!(AnthroLibrary::diversity_score(&[sample]), 0.0);
743    }
744
745    #[test]
746    fn test_add_custom_profile() {
747        let mut lib = AnthroLibrary::new();
748        lib.add(AnthroProfile {
749            name: "Custom_Test_Profile".into(),
750            height_mean_m: 1.75,
751            height_std_m: 0.05,
752            bmi_mean: 23.0,
753            bmi_std: 2.0,
754            shoulder_hip_ratio_mean: 1.08,
755            shoulder_hip_ratio_std: 0.04,
756            limb_torso_ratio_mean: 1.01,
757            limb_torso_ratio_std: 0.04,
758        });
759        assert_eq!(lib.count(), 1);
760        assert!(lib.find("Custom_Test_Profile").is_some());
761        write_tmp("ev_custom_profile.txt", "custom profile added OK");
762    }
763
764    #[test]
765    fn test_empty_library_sample_population() {
766        let lib = AnthroLibrary::new();
767        let pop = lib.sample_population(5, 0);
768        assert!(pop.is_empty());
769    }
770
771    #[test]
772    fn test_diversity_score_identical_samples() {
773        let s = AnthroSample {
774            height_m: 1.70,
775            bmi: 22.0,
776            shoulder_hip_ratio: 1.05,
777            limb_torso_ratio: 1.00,
778        };
779        // Two identical samples → distance = 0
780        let score = AnthroLibrary::diversity_score(&[s.clone(), s]);
781        assert_eq!(score, 0.0);
782    }
783}