1#![allow(dead_code)]
12
13use std::collections::HashMap;
14
15#[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 fn next_f32(&mut self) -> f32 {
42 (self.next_u64() >> 33) as f32 / (1u64 << 31) as f32
43 }
44
45 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
54pub fn lcg_normal(mean: f32, std: f32, seed: u32) -> f32 {
60 Lcg::new(seed).next_normal(mean, std)
61}
62
63pub 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
69pub 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
76pub fn age_to_param(age_years: f32) -> f32 {
80 ((age_years - 18.0) / (80.0 - 18.0)).clamp(0.0, 1.0)
81}
82
83pub fn bmi_to_params(height_m: f32, bmi: f32) -> (f32, f32) {
90 let _ = height_m; let weight_param = ((bmi - 15.0) / (40.0 - 15.0)).clamp(0.0, 1.0);
92 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#[derive(Debug, Clone)]
104pub struct AnthroSample {
105 pub height_m: f32,
107 pub bmi: f32,
109 pub shoulder_hip_ratio: f32,
111 pub limb_torso_ratio: f32,
113}
114
115impl AnthroSample {
116 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 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 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 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#[derive(Debug, Clone)]
154pub struct AnthroProfile {
155 pub name: String,
157 pub height_mean_m: f32,
159 pub height_std_m: f32,
161 pub bmi_mean: f32,
163 pub bmi_std: f32,
165 pub shoulder_hip_ratio_mean: f32,
167 pub shoulder_hip_ratio_std: f32,
169 pub limb_torso_ratio_mean: f32,
171 pub limb_torso_ratio_std: f32,
173}
174
175impl AnthroProfile {
176 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 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
203pub 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 pub fn new() -> Self {
221 Self {
222 profiles: Vec::new(),
223 }
224 }
225
226 pub fn add(&mut self, profile: AnthroProfile) {
228 self.profiles.push(profile);
229 }
230
231 pub fn find(&self, name: &str) -> Option<&AnthroProfile> {
233 self.profiles.iter().find(|p| p.name == name)
234 }
235
236 pub fn count(&self) -> usize {
238 self.profiles.len()
239 }
240
241 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 pub fn who_reference() -> Self {
259 let mut lib = Self::new();
260
261 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 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 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 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 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 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 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 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 pub fn diversity_score(samples: &[AnthroSample]) -> f32 {
388 if samples.len() < 2 {
389 return 0.0;
390 }
391
392 let h_range = 0.70_f32; let b_range = 25.0_f32; let s_range = 0.60_f32; let l_range = 0.60_f32; 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#[cfg(test)]
439mod tests {
440 use super::*;
441 use std::io::Write;
442
443 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 #[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 #[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 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 #[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 assert_eq!(height_m_to_param(1.00), 0.0);
516 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 #[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 #[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 #[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 let degenerate = s.normalized_height(1.70, 1.70);
601 assert_eq!(degenerate, 0.5);
602 }
603
604 #[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 ¶ms {
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 #[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 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 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 let score = AnthroLibrary::diversity_score(&[s.clone(), s]);
781 assert_eq!(score, 0.0);
782 }
783}