1use std::collections::VecDeque;
45
46#[derive(Debug, Clone)]
48pub struct PredictorConfig {
49 pub default_height: u16,
51 pub prior_strength: f64,
53 pub prior_mean: f64,
55 pub prior_variance: f64,
57 pub coverage: f64,
59 pub calibration_window: usize,
61}
62
63impl Default for PredictorConfig {
64 fn default() -> Self {
65 Self {
66 default_height: 1,
67 prior_strength: 2.0,
68 prior_mean: 1.0,
69 prior_variance: 4.0,
70 coverage: 0.90,
71 calibration_window: 200,
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
78struct WelfordStats {
79 n: u64,
80 mean: f64,
81 m2: f64, }
83
84impl WelfordStats {
85 fn new() -> Self {
86 Self {
87 n: 0,
88 mean: 0.0,
89 m2: 0.0,
90 }
91 }
92
93 fn update(&mut self, x: f64) {
94 self.n += 1;
95 let delta = x - self.mean;
96 self.mean += delta / self.n as f64;
97 let delta2 = x - self.mean;
98 self.m2 += delta * delta2;
99 }
100
101 fn variance(&self) -> f64 {
102 if self.n < 2 {
103 return f64::MAX;
104 }
105 self.m2 / (self.n - 1) as f64
106 }
107}
108
109#[derive(Debug, Clone)]
111struct CategoryState {
112 welford: WelfordStats,
114 posterior_mean: f64,
116 posterior_kappa: f64,
118 residuals: VecDeque<f64>,
120}
121
122#[derive(Debug, Clone, Copy)]
124pub struct HeightPrediction {
125 pub predicted: u16,
127 pub lower: u16,
129 pub upper: u16,
131 pub observations: u64,
133}
134
135#[derive(Debug, Clone)]
137pub struct HeightPredictor {
138 config: PredictorConfig,
139 categories: Vec<CategoryState>,
141 total_measurements: u64,
143 total_violations: u64,
145}
146
147impl HeightPredictor {
148 pub fn new(config: PredictorConfig) -> Self {
150 let default_cat = CategoryState {
152 welford: WelfordStats::new(),
153 posterior_mean: config.prior_mean,
154 posterior_kappa: config.prior_strength,
155 residuals: VecDeque::new(),
156 };
157 Self {
158 config,
159 categories: vec![default_cat],
160 total_measurements: 0,
161 total_violations: 0,
162 }
163 }
164
165 pub fn register_category(&mut self) -> usize {
167 let id = self.categories.len();
168 self.categories.push(CategoryState {
169 welford: WelfordStats::new(),
170 posterior_mean: self.config.prior_mean,
171 posterior_kappa: self.config.prior_strength,
172 residuals: VecDeque::new(),
173 });
174 id
175 }
176
177 pub fn predict(&self, category: usize) -> HeightPrediction {
179 let cat = match self.categories.get(category) {
180 Some(c) => c,
181 None => return self.cold_prediction(),
182 };
183
184 if cat.welford.n == 0 {
185 return self.cold_prediction();
186 }
187
188 let mu = cat.posterior_mean;
189 let predicted = mu.round().max(1.0) as u16;
190
191 let (lower, upper) = self.conformal_bounds(cat, mu);
193
194 HeightPrediction {
195 predicted,
196 lower,
197 upper,
198 observations: cat.welford.n,
199 }
200 }
201
202 pub fn observe(&mut self, category: usize, actual_height: u16) -> bool {
205 while self.categories.len() <= category {
207 self.register_category();
208 }
209
210 let prediction = self.predict(category);
211 let within_bounds = actual_height >= prediction.lower && actual_height <= prediction.upper;
212
213 self.total_measurements += 1;
214 if !within_bounds && prediction.observations > 0 {
215 self.total_violations += 1;
216 }
217
218 let cat = &mut self.categories[category];
219 let h = actual_height as f64;
220
221 let residual = (cat.posterior_mean - h).abs();
223 cat.residuals.push_back(residual);
224 if cat.residuals.len() > self.config.calibration_window {
225 cat.residuals.pop_front();
226 }
227
228 cat.welford.update(h);
230
231 let n = cat.welford.n as f64;
233 let kappa_0 = self.config.prior_strength;
234 let mu_0 = self.config.prior_mean;
235 cat.posterior_kappa = kappa_0 + n;
236 cat.posterior_mean = (kappa_0 * mu_0 + n * cat.welford.mean) / cat.posterior_kappa;
237
238 within_bounds
239 }
240
241 fn cold_prediction(&self) -> HeightPrediction {
243 let d = self.config.default_height;
244 let margin = (self.config.prior_variance.sqrt() * 2.0).ceil() as u16;
245 HeightPrediction {
246 predicted: d,
247 lower: d.saturating_sub(margin),
248 upper: d.saturating_add(margin),
249 observations: 0,
250 }
251 }
252
253 fn conformal_bounds(&self, cat: &CategoryState, mu: f64) -> (u16, u16) {
255 if cat.residuals.is_empty() {
256 let margin = (self.config.prior_variance.sqrt() * 2.0).ceil() as u16;
258 let predicted = mu.round().max(1.0) as u16;
259 return (
260 predicted.saturating_sub(margin),
261 predicted.saturating_add(margin),
262 );
263 }
264
265 let mut sorted: Vec<f64> = cat.residuals.iter().copied().collect();
267 sorted.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
268
269 let alpha = 1.0 - self.config.coverage;
270 let n = sorted.len() as f64;
271 let quantile_idx = ((1.0 - alpha) * (n + 1.0)).ceil() as usize;
272 let quantile_idx = quantile_idx.min(sorted.len()).saturating_sub(1);
273 let q = sorted[quantile_idx];
274
275 let lower = (mu - q).max(1.0).floor() as u16;
276 let upper = (mu + q).ceil().max(1.0) as u16;
277
278 (lower, upper)
279 }
280
281 pub fn posterior_mean(&self, category: usize) -> f64 {
283 self.categories
284 .get(category)
285 .map(|c| c.posterior_mean)
286 .unwrap_or(self.config.prior_mean)
287 }
288
289 pub fn posterior_variance(&self, category: usize) -> f64 {
291 self.categories
292 .get(category)
293 .map(|c| {
294 let sigma_sq = if c.welford.n < 2 {
295 self.config.prior_variance
296 } else {
297 c.welford.variance()
298 };
299 sigma_sq / c.posterior_kappa
300 })
301 .unwrap_or(self.config.prior_variance)
302 }
303
304 pub fn total_measurements(&self) -> u64 {
306 self.total_measurements
307 }
308
309 pub fn total_violations(&self) -> u64 {
311 self.total_violations
312 }
313
314 pub fn violation_rate(&self) -> f64 {
316 if self.total_measurements == 0 {
317 return 0.0;
318 }
319 self.total_violations as f64 / self.total_measurements as f64
320 }
321
322 pub fn category_count(&self) -> usize {
324 self.categories.len()
325 }
326
327 pub fn category_observations(&self, category: usize) -> u64 {
329 self.categories
330 .get(category)
331 .map(|c| c.welford.n)
332 .unwrap_or(0)
333 }
334}
335
336impl Default for HeightPredictor {
337 fn default() -> Self {
338 Self::new(PredictorConfig::default())
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
349 fn unit_posterior_update() {
350 let config = PredictorConfig {
351 prior_mean: 2.0,
352 prior_strength: 1.0,
353 prior_variance: 4.0,
354 ..Default::default()
355 };
356 let mut pred = HeightPredictor::new(config);
357
358 assert!((pred.posterior_mean(0) - 2.0).abs() < 1e-10);
360
361 pred.observe(0, 4);
363 assert!((pred.posterior_mean(0) - 3.0).abs() < 1e-10);
365
366 pred.observe(0, 4);
368 assert!((pred.posterior_mean(0) - 10.0 / 3.0).abs() < 1e-10);
370 }
371
372 #[test]
373 fn unit_posterior_variance_decreases() {
374 let mut pred = HeightPredictor::new(PredictorConfig {
375 prior_variance: 4.0,
376 ..Default::default()
377 });
378
379 let var_0 = pred.posterior_variance(0);
380 assert!(var_0 > 0.0, "prior variance should be positive");
381
382 for i in 0..10 {
384 pred.observe(0, if i % 2 == 0 { 2 } else { 4 });
385 }
386 let var_10 = pred.posterior_variance(0);
387
388 for i in 0..90 {
389 pred.observe(0, if i % 2 == 0 { 2 } else { 4 });
390 }
391 let var_100 = pred.posterior_variance(0);
392
393 assert!(
395 var_10 < var_0,
396 "variance should decrease: {var_10} >= {var_0}"
397 );
398 assert!(
399 var_100 < var_10,
400 "variance should decrease: {var_100} >= {var_10}"
401 );
402 }
403
404 #[test]
407 fn unit_conformal_bounds() {
408 let config = PredictorConfig {
409 coverage: 0.90,
410 prior_mean: 3.0,
411 prior_strength: 1.0,
412 ..Default::default()
413 };
414 let mut pred = HeightPredictor::new(config);
415
416 for _ in 0..50 {
418 pred.observe(0, 3);
419 }
420
421 let p = pred.predict(0);
422 assert_eq!(p.predicted, 3);
425 assert!(p.lower <= 3);
426 assert!(p.upper >= 3);
427 }
428
429 #[test]
430 fn conformal_bounds_widen_with_noise() {
431 let config = PredictorConfig {
432 coverage: 0.90,
433 prior_mean: 5.0,
434 prior_strength: 1.0,
435 ..Default::default()
436 };
437 let mut pred = HeightPredictor::new(config);
438
439 for _ in 0..50 {
441 pred.observe(0, 5);
442 }
443 let tight = pred.predict(0);
444
445 let mut pred2 = HeightPredictor::new(PredictorConfig {
447 coverage: 0.90,
448 prior_mean: 5.0,
449 prior_strength: 1.0,
450 ..Default::default()
451 });
452 let mut seed: u64 = 0xABCD_1234_5678_9ABC;
453 for _ in 0..50 {
454 seed = seed
455 .wrapping_mul(6364136223846793005)
456 .wrapping_add(1442695040888963407);
457 let h = 3 + (seed >> 62) as u16; pred2.observe(0, h);
459 }
460 let wide = pred2.predict(0);
461
462 assert!(
463 (wide.upper - wide.lower) >= (tight.upper - tight.lower),
464 "noisy data should produce wider bounds"
465 );
466 }
467
468 #[test]
471 fn property_coverage() {
472 let alpha = 0.10;
473 let config = PredictorConfig {
474 coverage: 1.0 - alpha,
475 prior_mean: 3.0,
476 prior_strength: 2.0,
477 prior_variance: 4.0,
478 calibration_window: 100,
479 ..Default::default()
480 };
481 let mut pred = HeightPredictor::new(config);
482
483 let mut seed: u64 = 0xDEAD_BEEF_CAFE_0001;
485 for _ in 0..100 {
486 seed = seed
487 .wrapping_mul(6364136223846793005)
488 .wrapping_add(1442695040888963407);
489 let h = 2 + (seed >> 62) as u16; pred.observe(0, h);
491 }
492
493 let mut violations = 0u32;
495 let test_n = 200;
496 for _ in 0..test_n {
497 seed = seed
498 .wrapping_mul(6364136223846793005)
499 .wrapping_add(1442695040888963407);
500 let h = 2 + (seed >> 62) as u16;
501 let within = pred.observe(0, h);
502 if !within {
503 violations += 1;
504 }
505 }
506
507 let viol_rate = violations as f64 / test_n as f64;
508 assert!(
511 viol_rate <= alpha + 0.15,
512 "violation rate {viol_rate} exceeds α + tolerance ({alpha} + 0.15)"
513 );
514 }
515
516 #[test]
519 fn e2e_scroll_stability() {
520 let mut pred = HeightPredictor::new(PredictorConfig {
521 prior_mean: 1.0,
522 prior_strength: 2.0,
523 default_height: 1,
524 coverage: 0.90,
525 ..Default::default()
526 });
527
528 let mut corrections = 0u32;
530 for _ in 0..500 {
531 let within = pred.observe(0, 1);
532 if !within {
533 corrections += 1;
534 }
535 }
536
537 let p = pred.predict(0);
540 assert_eq!(p.predicted, 1);
541 assert!(corrections < 10, "too many corrections: {corrections}");
542 }
543
544 #[test]
547 fn categories_are_independent() {
548 let mut pred = HeightPredictor::default();
549 let cat_a = 0;
550 let cat_b = pred.register_category();
551
552 for _ in 0..20 {
554 pred.observe(cat_a, 1);
555 pred.observe(cat_b, 5);
556 }
557
558 let pa = pred.predict(cat_a);
559 let pb = pred.predict(cat_b);
560
561 assert_eq!(pa.predicted, 1);
562 assert!(pb.predicted >= 4 && pb.predicted <= 5);
563 }
564
565 #[test]
568 fn cold_prediction_uses_default() {
569 let pred = HeightPredictor::new(PredictorConfig {
570 default_height: 2,
571 prior_variance: 1.0,
572 ..Default::default()
573 });
574 let p = pred.predict(0);
575 assert_eq!(p.predicted, 2);
576 assert_eq!(p.observations, 0);
577 }
578
579 #[test]
582 fn deterministic_under_same_observations() {
583 let run = || {
584 let mut pred = HeightPredictor::default();
585 let observations = [1, 2, 1, 3, 1, 2, 1, 1, 4, 1];
586 for &h in &observations {
587 pred.observe(0, h);
588 }
589 (pred.predict(0).predicted, pred.posterior_mean(0))
590 };
591
592 let (p1, m1) = run();
593 let (p2, m2) = run();
594 assert_eq!(p1, p2);
595 assert!((m1 - m2).abs() < 1e-15);
596 }
597
598 #[test]
601 fn perf_prediction_overhead() {
602 let mut pred = HeightPredictor::default();
603
604 for _ in 0..100 {
606 pred.observe(0, 2);
607 }
608
609 let start = std::time::Instant::now();
610 let mut _sink = 0u16;
611 for _ in 0..100_000 {
612 _sink = _sink.wrapping_add(pred.predict(0).predicted);
613 }
614 let elapsed = start.elapsed();
615 let per_prediction = elapsed / 100_000;
616
617 assert!(
619 per_prediction < std::time::Duration::from_micros(5),
620 "prediction too slow: {per_prediction:?}"
621 );
622 }
623
624 #[test]
627 fn violation_tracking() {
628 let mut pred = HeightPredictor::new(PredictorConfig {
629 prior_mean: 5.0,
630 prior_strength: 100.0, default_height: 5,
632 coverage: 0.95,
633 ..Default::default()
634 });
635
636 for _ in 0..50 {
638 pred.observe(0, 5);
639 }
640
641 let within = pred.observe(0, 20);
643 assert!(!within, "extreme outlier should violate bounds");
644 assert!(pred.total_violations() > 0);
645 }
646
647 #[test]
650 fn config_default_values() {
651 let config = PredictorConfig::default();
652 assert_eq!(config.default_height, 1);
653 assert!((config.prior_strength - 2.0).abs() < f64::EPSILON);
654 assert!((config.prior_mean - 1.0).abs() < f64::EPSILON);
655 assert!((config.prior_variance - 4.0).abs() < f64::EPSILON);
656 assert!((config.coverage - 0.90).abs() < f64::EPSILON);
657 assert_eq!(config.calibration_window, 200);
658 }
659
660 #[test]
663 fn default_predictor_has_one_category() {
664 let pred = HeightPredictor::default();
665 assert_eq!(pred.category_count(), 1);
666 assert_eq!(pred.total_measurements(), 0);
667 assert_eq!(pred.total_violations(), 0);
668 assert!((pred.violation_rate() - 0.0).abs() < f64::EPSILON);
669 }
670
671 #[test]
674 fn predict_unknown_category_returns_cold() {
675 let pred = HeightPredictor::default();
676 let p = pred.predict(999);
677 assert_eq!(p.predicted, pred.config.default_height);
678 assert_eq!(p.observations, 0);
679 }
680
681 #[test]
684 fn observe_auto_creates_categories() {
685 let mut pred = HeightPredictor::default();
686 assert_eq!(pred.category_count(), 1);
687 pred.observe(3, 5);
688 assert_eq!(pred.category_count(), 4);
690 assert_eq!(pred.category_observations(3), 1);
691 }
692
693 #[test]
696 fn violation_rate_empty() {
697 let pred = HeightPredictor::default();
698 assert!((pred.violation_rate() - 0.0).abs() < f64::EPSILON);
699 }
700
701 #[test]
702 fn violation_rate_computation() {
703 let mut pred = HeightPredictor::new(PredictorConfig {
704 prior_mean: 5.0,
705 prior_strength: 100.0,
706 default_height: 5,
707 coverage: 0.95,
708 ..Default::default()
709 });
710 for _ in 0..50 {
712 pred.observe(0, 5);
713 }
714 for _ in 0..10 {
716 pred.observe(0, 5);
717 }
718 let before_violations = pred.total_violations();
719 pred.observe(0, 100);
721 let after_violations = pred.total_violations();
722 assert!(after_violations > before_violations);
723 assert!(pred.violation_rate() > 0.0);
724 }
725
726 #[test]
729 fn category_observations_returns_zero_for_unknown() {
730 let pred = HeightPredictor::default();
731 assert_eq!(pred.category_observations(999), 0);
732 }
733
734 #[test]
735 fn category_observations_tracks_counts() {
736 let mut pred = HeightPredictor::default();
737 pred.observe(0, 3);
738 pred.observe(0, 4);
739 pred.observe(0, 5);
740 assert_eq!(pred.category_observations(0), 3);
741 }
742
743 #[test]
746 fn posterior_mean_unknown_returns_prior() {
747 let pred = HeightPredictor::default();
748 assert!((pred.posterior_mean(999) - pred.config.prior_mean).abs() < f64::EPSILON);
749 }
750
751 #[test]
752 fn posterior_variance_unknown_returns_prior() {
753 let pred = HeightPredictor::default();
754 assert!((pred.posterior_variance(999) - pred.config.prior_variance).abs() < f64::EPSILON);
755 }
756
757 #[test]
760 fn register_category_returns_sequential_ids() {
761 let mut pred = HeightPredictor::default();
762 let id1 = pred.register_category();
763 let id2 = pred.register_category();
764 assert_eq!(id1, 1);
765 assert_eq!(id2, 2);
766 assert_eq!(pred.category_count(), 3);
767 }
768
769 #[test]
772 fn observe_returns_true_for_consistent_data() {
773 let mut pred = HeightPredictor::new(PredictorConfig {
774 prior_mean: 3.0,
775 prior_strength: 1.0,
776 ..Default::default()
777 });
778 for _ in 0..20 {
780 pred.observe(0, 3);
781 }
782 assert!(pred.observe(0, 3));
784 }
785
786 #[test]
789 fn total_measurements_increments() {
790 let mut pred = HeightPredictor::default();
791 for i in 0..7 {
792 pred.observe(0, (i + 1) as u16);
793 }
794 assert_eq!(pred.total_measurements(), 7);
795 }
796
797 #[test]
800 fn prediction_lower_le_predicted_le_upper() {
801 let mut pred = HeightPredictor::default();
802 for _ in 0..30 {
803 pred.observe(0, 3);
804 }
805 let p = pred.predict(0);
806 assert!(p.lower <= p.predicted);
807 assert!(p.predicted <= p.upper);
808 }
809
810 #[test]
813 fn observe_height_zero() {
814 let mut pred = HeightPredictor::default();
815 pred.observe(0, 0);
816 let p = pred.predict(0);
817 assert!(p.predicted >= 1);
819 }
820
821 #[test]
822 fn observe_height_max_u16() {
823 let mut pred = HeightPredictor::default();
824 pred.observe(0, u16::MAX);
825 let p = pred.predict(0);
826 assert!(p.predicted > 0);
827 assert!(p.observations == 1);
828 }
829
830 #[test]
831 fn cold_prediction_zero_variance() {
832 let pred = HeightPredictor::new(PredictorConfig {
833 default_height: 5,
834 prior_variance: 0.0,
835 ..Default::default()
836 });
837 let p = pred.predict(0);
838 assert_eq!(p.predicted, 5);
839 assert_eq!(p.lower, 5);
841 assert_eq!(p.upper, 5);
842 }
843
844 #[test]
845 fn cold_prediction_large_variance() {
846 let pred = HeightPredictor::new(PredictorConfig {
847 default_height: 1,
848 prior_variance: 10000.0,
849 ..Default::default()
850 });
851 let p = pred.predict(0);
852 assert_eq!(p.predicted, 1);
853 assert_eq!(p.lower, 0); }
856
857 #[test]
858 fn coverage_zero() {
859 let mut pred = HeightPredictor::new(PredictorConfig {
860 coverage: 0.0,
861 prior_mean: 3.0,
862 prior_strength: 1.0,
863 ..Default::default()
864 });
865 for _ in 0..20 {
866 pred.observe(0, 3);
867 }
868 let p = pred.predict(0);
870 assert!(p.predicted > 0);
871 }
872
873 #[test]
874 fn coverage_one() {
875 let mut pred = HeightPredictor::new(PredictorConfig {
876 coverage: 1.0,
877 prior_mean: 3.0,
878 prior_strength: 1.0,
879 ..Default::default()
880 });
881 for _ in 0..20 {
882 pred.observe(0, 3);
883 }
884 for _ in 0..5 {
885 pred.observe(0, 10);
886 }
887 let p = pred.predict(0);
889 assert!(p.lower <= p.predicted);
890 assert!(p.predicted <= p.upper);
891 }
892
893 #[test]
894 fn calibration_window_one() {
895 let mut pred = HeightPredictor::new(PredictorConfig {
896 calibration_window: 1,
897 prior_mean: 3.0,
898 prior_strength: 1.0,
899 ..Default::default()
900 });
901 for _ in 0..10 {
902 pred.observe(0, 3);
903 }
904 let p = pred.predict(0);
905 assert!(p.predicted > 0);
906 assert!(p.lower <= p.predicted);
907 }
908
909 #[test]
910 fn single_observation_uses_wide_bounds() {
911 let mut pred = HeightPredictor::new(PredictorConfig {
912 prior_mean: 5.0,
913 prior_strength: 1.0,
914 prior_variance: 4.0,
915 ..Default::default()
916 });
917 pred.observe(0, 5);
918 let p = pred.predict(0);
919 assert_eq!(p.observations, 1);
920 assert!(p.lower <= p.predicted);
922 assert!(p.predicted <= p.upper);
923 }
924
925 #[test]
926 fn predictor_config_clone_and_debug() {
927 let config = PredictorConfig::default();
928 let cloned = config.clone();
929 assert_eq!(cloned.default_height, config.default_height);
930 let dbg = format!("{:?}", config);
931 assert!(dbg.contains("PredictorConfig"));
932 }
933
934 #[test]
935 fn height_prediction_copy_and_debug() {
936 let p = HeightPrediction {
937 predicted: 3,
938 lower: 1,
939 upper: 5,
940 observations: 10,
941 };
942 let p2 = p; assert_eq!(p.predicted, p2.predicted);
944 assert_eq!(p.lower, p2.lower);
945 assert_eq!(p.upper, p2.upper);
946 assert_eq!(p.observations, p2.observations);
947 let dbg = format!("{:?}", p);
948 assert!(dbg.contains("HeightPrediction"));
949 }
950
951 #[test]
952 fn height_prediction_clone() {
953 fn assert_clone<T: Clone>() {}
954 assert_clone::<HeightPrediction>();
955 let p = HeightPrediction {
956 predicted: 2,
957 lower: 1,
958 upper: 4,
959 observations: 5,
960 };
961 let cloned = p; assert_eq!(cloned.predicted, 2);
963 }
964
965 #[test]
966 fn predictor_clone_independence() {
967 let mut pred = HeightPredictor::default();
968 pred.observe(0, 5);
969 pred.observe(0, 5);
970 let mut cloned = pred.clone();
971 cloned.observe(0, 100);
972 assert_eq!(pred.total_measurements(), 2);
974 assert_eq!(cloned.total_measurements(), 3);
975 }
976
977 #[test]
978 fn predictor_debug() {
979 let pred = HeightPredictor::default();
980 let dbg = format!("{:?}", pred);
981 assert!(dbg.contains("HeightPredictor"));
982 }
983
984 #[test]
985 fn posterior_variance_with_two_identical_observations() {
986 let mut pred = HeightPredictor::new(PredictorConfig {
987 prior_variance: 4.0,
988 prior_strength: 1.0,
989 ..Default::default()
990 });
991 pred.observe(0, 3);
992 pred.observe(0, 3);
993 let var = pred.posterior_variance(0);
996 assert!(var.abs() < 1e-10, "identical obs should give ~0 variance");
997 }
998
999 #[test]
1000 fn posterior_variance_with_one_observation_uses_prior() {
1001 let mut pred = HeightPredictor::new(PredictorConfig {
1002 prior_variance: 4.0,
1003 prior_strength: 2.0,
1004 ..Default::default()
1005 });
1006 pred.observe(0, 3);
1007 let var = pred.posterior_variance(0);
1011 assert!((var - 4.0 / 3.0).abs() < 1e-10);
1012 }
1013
1014 #[test]
1015 fn observe_returns_false_for_first_cold_outlier() {
1016 let mut pred = HeightPredictor::new(PredictorConfig {
1017 default_height: 1,
1018 prior_mean: 1.0,
1019 prior_strength: 2.0,
1020 prior_variance: 0.25,
1021 ..Default::default()
1022 });
1023 let within = pred.observe(0, 100);
1027 assert!(within || pred.total_violations() == 0);
1029 }
1030
1031 #[test]
1032 fn all_same_height_converges_exactly() {
1033 let mut pred = HeightPredictor::new(PredictorConfig {
1034 prior_mean: 3.0,
1035 prior_strength: 1.0,
1036 ..Default::default()
1037 });
1038 for _ in 0..100 {
1039 pred.observe(0, 3);
1040 }
1041 let p = pred.predict(0);
1042 assert_eq!(p.predicted, 3);
1043 assert_eq!(p.lower, 3);
1045 assert_eq!(p.upper, 3);
1046 }
1047
1048 #[test]
1049 fn many_categories_auto_created() {
1050 let mut pred = HeightPredictor::default();
1051 pred.observe(10, 5);
1052 assert_eq!(pred.category_count(), 11);
1054 assert_eq!(pred.category_observations(5), 0);
1056 assert_eq!(pred.category_observations(10), 1);
1057 }
1058
1059 #[test]
1060 fn prediction_bounds_ordering_after_mixed_data() {
1061 let mut pred = HeightPredictor::default();
1062 for h in [1, 2, 5, 10, 1, 3, 7, 2, 4, 6] {
1063 pred.observe(0, h);
1064 }
1065 let p = pred.predict(0);
1066 assert!(
1067 p.lower <= p.predicted,
1068 "lower={} > predicted={}",
1069 p.lower,
1070 p.predicted
1071 );
1072 assert!(
1073 p.predicted <= p.upper,
1074 "predicted={} > upper={}",
1075 p.predicted,
1076 p.upper
1077 );
1078 }
1079}