1#![deny(unsafe_code)]
2
3#[cfg(feature = "serde")]
4use serde::{Serialize, Deserialize};
5
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
22pub enum Phase {
23 Stable,
25 PreTransition,
30 Transitioning,
32 Resolving,
34}
35
36impl std::fmt::Display for Phase {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Phase::Stable => write!(f, "Stable"),
40 Phase::PreTransition => write!(f, "PreTransition"),
41 Phase::Transitioning => write!(f, "Transitioning"),
42 Phase::Resolving => write!(f, "Resolving"),
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
48#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
49struct QuantityState {
50 initial: f64,
51 current: f64,
52 tolerance: f64,
53 history: Vec<f64>,
54}
55
56#[derive(Debug, Clone)]
62#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
63pub struct ConservationChecker {
64 quantities: HashMap<String, QuantityState>,
65}
66
67impl Default for ConservationChecker {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl ConservationChecker {
74 pub fn new() -> Self {
85 Self {
86 quantities: HashMap::new(),
87 }
88 }
89
90 pub fn register(&mut self, name: impl Into<String>, initial_value: f64, tolerance: f64) {
109 let name = name.into();
110 self.quantities.insert(
111 name,
112 QuantityState {
113 initial: initial_value,
114 current: initial_value,
115 tolerance,
116 history: vec![initial_value],
117 },
118 );
119 }
120
121 pub fn update(&mut self, name: &str, value: f64) {
143 let state = self
144 .quantities
145 .get_mut(name)
146 .unwrap_or_else(|| panic!("quantity '{}' not registered", name));
147 state.current = value;
148 }
149
150 pub fn is_conserved(&self, name: &str) -> bool {
159 let state = self
160 .quantities
161 .get(name)
162 .unwrap_or_else(|| panic!("quantity '{}' not registered", name));
163 state.current >= state.initial - state.tolerance
164 }
165
166 pub fn violations(&self) -> Vec<String> {
168 self.quantities
169 .iter()
170 .filter(|(_, state)| state.current < state.initial - state.tolerance)
171 .map(|(name, _)| name.clone())
172 .collect()
173 }
174
175 pub fn snapshot(&mut self) {
200 for state in self.quantities.values_mut() {
201 state.history.push(state.current);
202 }
203 }
204
205 pub fn phase(&self, name: &str) -> Phase {
234 let state = self
235 .quantities
236 .get(name)
237 .unwrap_or_else(|| panic!("quantity '{}' not registered", name));
238
239 let _rate = self.drift_rate(name);
240
241 if state.history.len() < 3 {
242 return Phase::Stable;
243 }
244
245 let is_violated = !self.is_conserved(name);
246
247 let len = state.history.len();
248
249 let recent_rate = if len >= 2 {
251 state.history[len - 1] - state.history[len - 2]
252 } else {
253 0.0
254 };
255
256 let older_rate = if len >= 4 {
258 state.history[len - 3] - state.history[len - 4]
259 } else {
260 0.0
261 };
262
263 let abs_recent = recent_rate.abs();
265 let noise_floor = state.tolerance.max(1.0) * 0.01;
266
267 if is_violated && recent_rate < -noise_floor {
268 Phase::Transitioning
269 } else if is_violated && recent_rate > noise_floor {
270 Phase::Resolving
271 } else if !is_violated && abs_recent > noise_floor && abs_recent > older_rate.abs() {
272 Phase::PreTransition
273 } else {
274 Phase::Stable
275 }
276 }
277
278 pub fn drift_rate(&self, name: &str) -> f64 {
287 let state = self
288 .quantities
289 .get(name)
290 .unwrap_or_else(|| panic!("quantity '{}' not registered", name));
291
292 if state.history.len() < 2 {
293 return 0.0;
294 }
295
296 let n = state.history.len() as f64;
297 (state.history.last().unwrap() - state.history.first().unwrap()) / (n - 1.0)
299 }
300
301 pub fn current_value(&self, name: &str) -> f64 {
307 let state = self
308 .quantities
309 .get(name)
310 .unwrap_or_else(|| panic!("quantity '{}' not registered", name));
311 state.current
312 }
313
314 pub fn initial_value(&self, name: &str) -> f64 {
320 let state = self
321 .quantities
322 .get(name)
323 .unwrap_or_else(|| panic!("quantity '{}' not registered", name));
324 state.initial
325 }
326
327 pub fn snapshot_count(&self, name: &str) -> usize {
333 let state = self
334 .quantities
335 .get(name)
336 .unwrap_or_else(|| panic!("quantity '{}' not registered", name));
337 state.history.len()
338 }
339
340 pub fn registered(&self) -> Vec<String> {
342 self.quantities.keys().cloned().collect()
343 }
344
345 pub fn deregister(&mut self, name: &str) -> bool {
349 self.quantities.remove(name).is_some()
350 }
351
352 pub fn reset_baseline(&mut self, name: &str) {
377 let state = self
378 .quantities
379 .get_mut(name)
380 .unwrap_or_else(|| panic!("quantity '{}' not registered", name));
381 state.initial = state.current;
382 }
383
384 #[cfg(feature = "serde")]
392 pub fn snapshot_json(&self) -> String {
393 use serde_json::json;
394 let quantities: Vec<serde_json::Value> = self
395 .quantities
396 .iter()
397 .map(|(name, state)| {
398 json!({
399 "name": name,
400 "initial": state.initial,
401 "current": state.current,
402 "tolerance": state.tolerance,
403 "conserved": state.current >= state.initial - state.tolerance,
404 })
405 })
406 .collect();
407 serde_json::to_string(&quantities).unwrap_or_else(|_| "[]".to_string())
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
418 fn new_tracker_is_empty() {
419 let c = ConservationChecker::new();
420 assert!(c.registered().is_empty());
421 assert!(c.violations().is_empty());
422 }
423
424 #[test]
425 fn default_equals_new() {
426 let a = ConservationChecker::new();
427 let b = ConservationChecker::default();
428 assert_eq!(a.registered(), b.registered());
429 assert_eq!(a.violations(), b.violations());
430 }
431
432 #[test]
433 fn register_single_quantity() {
434 let mut c = ConservationChecker::new();
435 c.register("energy", 100.0, 0.0);
436 assert_eq!(c.registered(), vec!["energy"]);
437 }
438
439 #[test]
440 fn register_multiple_quantities() {
441 let mut c = ConservationChecker::new();
442 c.register("a", 10.0, 0.0);
443 c.register("b", 20.0, 0.0);
444 c.register("c", 30.0, 0.0);
445 let mut names = c.registered();
446 names.sort();
447 assert_eq!(names, vec!["a", "b", "c"]);
448 }
449
450 #[test]
451 fn register_overwrites_existing() {
452 let mut c = ConservationChecker::new();
453 c.register("x", 50.0, 1.0);
454 c.register("x", 99.0, 2.0);
455 assert_eq!(c.current_value("x"), 99.0);
456 assert!((c.initial_value("x") - 99.0).abs() < f64::EPSILON);
457 }
458
459 #[test]
460 fn initial_and_current_match_after_register() {
461 let mut c = ConservationChecker::new();
462 c.register("tokens", 42.0, 0.0);
463 assert!((c.initial_value("tokens") - 42.0).abs() < f64::EPSILON);
464 assert!((c.current_value("tokens") - 42.0).abs() < f64::EPSILON);
465 }
466
467 #[test]
470 fn update_changes_current_value() {
471 let mut c = ConservationChecker::new();
472 c.register("budget", 1000.0, 0.0);
473 c.update("budget", 950.0);
474 assert!((c.current_value("budget") - 950.0).abs() < f64::EPSILON);
475 }
476
477 #[test]
478 #[should_panic(expected = "not registered")]
479 fn update_panics_on_unknown_name() {
480 let mut c = ConservationChecker::new();
481 c.update("ghost", 10.0);
482 }
483
484 #[test]
485 fn update_to_same_value_is_fine() {
486 let mut c = ConservationChecker::new();
487 c.register("q", 10.0, 0.0);
488 c.update("q", 10.0);
489 assert!(c.is_conserved("q"));
490 }
491
492 #[test]
493 fn update_to_higher_value_is_fine() {
494 let mut c = ConservationChecker::new();
495 c.register("q", 10.0, 0.0);
496 c.update("q", 999.0);
497 assert!(c.is_conserved("q"));
498 }
499
500 #[test]
503 fn is_conserved_no_change() {
504 let mut c = ConservationChecker::new();
505 c.register("energy", 100.0, 0.0);
506 assert!(c.is_conserved("energy"));
507 }
508
509 #[test]
510 fn is_conserved_increase_ok() {
511 let mut c = ConservationChecker::new();
512 c.register("energy", 100.0, 0.0);
513 c.update("energy", 150.0);
514 assert!(c.is_conserved("energy"));
515 }
516
517 #[test]
518 fn is_conserved_decrease_violates_strict() {
519 let mut c = ConservationChecker::new();
520 c.register("energy", 100.0, 0.0);
521 c.update("energy", 99.9);
522 assert!(!c.is_conserved("energy"));
523 }
524
525 #[test]
526 fn is_conserved_within_tolerance() {
527 let mut c = ConservationChecker::new();
528 c.register("energy", 100.0, 5.0);
529 c.update("energy", 96.0);
530 assert!(c.is_conserved("energy"));
531 }
532
533 #[test]
534 fn is_conserved_exactly_at_tolerance_boundary() {
535 let mut c = ConservationChecker::new();
536 c.register("energy", 100.0, 5.0);
537 c.update("energy", 95.0); assert!(c.is_conserved("energy"));
539 }
540
541 #[test]
542 fn is_conserved_just_past_tolerance() {
543 let mut c = ConservationChecker::new();
544 c.register("energy", 100.0, 5.0);
545 c.update("energy", 94.999);
546 assert!(!c.is_conserved("energy"));
547 }
548
549 #[test]
550 #[should_panic(expected = "not registered")]
551 fn is_conserved_panics_on_unknown() {
552 ConservationChecker::new().is_conserved("nope");
553 }
554
555 #[test]
558 fn violations_none_when_all_ok() {
559 let mut c = ConservationChecker::new();
560 c.register("a", 10.0, 0.0);
561 c.register("b", 20.0, 0.0);
562 assert!(c.violations().is_empty());
563 }
564
565 #[test]
566 fn violations_reports_decreased() {
567 let mut c = ConservationChecker::new();
568 c.register("a", 10.0, 0.0);
569 c.register("b", 20.0, 0.0);
570 c.update("a", 5.0);
571 let v = c.violations();
572 assert_eq!(v, vec!["a"]);
573 }
574
575 #[test]
576 fn violations_multiple() {
577 let mut c = ConservationChecker::new();
578 c.register("a", 10.0, 0.0);
579 c.register("b", 20.0, 0.0);
580 c.register("c", 30.0, 0.0);
581 c.update("a", 5.0);
582 c.update("c", 25.0);
583 let mut v = c.violations();
584 v.sort();
585 assert_eq!(v, vec!["a", "c"]);
586 }
587
588 #[test]
591 fn snapshot_increments_count() {
592 let mut c = ConservationChecker::new();
593 c.register("q", 10.0, 0.0);
594 assert_eq!(c.snapshot_count("q"), 1); c.snapshot();
596 assert_eq!(c.snapshot_count("q"), 2);
597 c.snapshot();
598 assert_eq!(c.snapshot_count("q"), 3);
599 }
600
601 #[test]
602 fn snapshot_records_current_values() {
603 let mut c = ConservationChecker::new();
604 c.register("x", 100.0, 0.0);
605 c.update("x", 90.0);
606 c.snapshot();
607 assert!((c.drift_rate("x") - (-10.0)).abs() < f64::EPSILON);
609 }
610
611 #[test]
612 fn snapshot_captures_all_quantities() {
613 let mut c = ConservationChecker::new();
614 c.register("a", 10.0, 0.0);
615 c.register("b", 20.0, 0.0);
616 c.snapshot();
617 assert_eq!(c.snapshot_count("a"), 2);
618 assert_eq!(c.snapshot_count("b"), 2);
619 }
620
621 #[test]
624 fn drift_rate_zero_with_one_snapshot() {
625 let mut c = ConservationChecker::new();
626 c.register("q", 10.0, 0.0);
627 assert!((c.drift_rate("q") - 0.0).abs() < f64::EPSILON);
628 }
629
630 #[test]
631 fn drift_rate_positive_on_increase() {
632 let mut c = ConservationChecker::new();
633 c.register("q", 10.0, 0.0);
634 c.update("q", 20.0);
635 c.snapshot();
636 assert!((c.drift_rate("q") - 10.0).abs() < f64::EPSILON);
638 }
639
640 #[test]
641 fn drift_rate_negative_on_decrease() {
642 let mut c = ConservationChecker::new();
643 c.register("q", 100.0, 0.0);
644 c.update("q", 90.0);
645 c.snapshot();
646 assert!((c.drift_rate("q") - (-10.0)).abs() < f64::EPSILON);
648 }
649
650 #[test]
651 fn drift_rate_averages_over_many_snapshots() {
652 let mut c = ConservationChecker::new();
653 c.register("q", 0.0, 0.0);
654 for v in [10.0, 20.0, 30.0] {
656 c.update("q", v);
657 c.snapshot();
658 }
659 assert!((c.drift_rate("q") - 10.0).abs() < f64::EPSILON);
661 }
662
663 #[test]
664 #[should_panic(expected = "not registered")]
665 fn drift_rate_panics_on_unknown() {
666 ConservationChecker::new().drift_rate("nope");
667 }
668
669 #[test]
672 fn phase_stable_when_no_change() {
673 let mut c = ConservationChecker::new();
674 c.register("q", 100.0, 0.0);
675 c.snapshot();
676 c.snapshot();
677 assert_eq!(c.phase("q"), Phase::Stable);
678 }
679
680 #[test]
681 fn phase_transitioning_when_decreasing_and_violated() {
682 let mut c = ConservationChecker::new();
683 c.register("q", 100.0, 0.0);
684 c.update("q", 90.0);
685 c.snapshot();
686 c.update("q", 80.0);
687 c.snapshot();
688 assert_eq!(c.phase("q"), Phase::Transitioning);
689 }
690
691 #[test]
692 fn phase_resolving_when_violated_but_recovering() {
693 let mut c = ConservationChecker::new();
694 c.register("q", 100.0, 5.0);
695 c.update("q", 90.0);
697 c.snapshot();
698 c.update("q", 80.0);
699 c.snapshot();
700 c.update("q", 92.0);
702 c.snapshot();
703 assert_eq!(c.phase("q"), Phase::Resolving);
704 }
705
706 #[test]
707 fn phase_pre_transition_when_accelerating() {
708 let mut c = ConservationChecker::new();
709 c.register("q", 100.0, 50.0); c.update("q", 99.0);
711 c.snapshot();
712 c.update("q", 96.0); c.snapshot();
714 assert_eq!(c.phase("q"), Phase::PreTransition);
716 }
717
718 #[test]
719 fn phase_stable_with_few_snapshots() {
720 let mut c = ConservationChecker::new();
721 c.register("q", 10.0, 0.0);
722 assert_eq!(c.phase("q"), Phase::Stable);
724 }
725
726 #[test]
727 #[should_panic(expected = "not registered")]
728 fn phase_panics_on_unknown() {
729 ConservationChecker::new().phase("nope");
730 }
731
732 #[test]
735 fn phase_display() {
736 assert_eq!(format!("{}", Phase::Stable), "Stable");
737 assert_eq!(format!("{}", Phase::PreTransition), "PreTransition");
738 assert_eq!(format!("{}", Phase::Transitioning), "Transitioning");
739 assert_eq!(format!("{}", Phase::Resolving), "Resolving");
740 }
741
742 #[test]
745 fn deregister_removes_quantity() {
746 let mut c = ConservationChecker::new();
747 c.register("x", 10.0, 0.0);
748 assert!(c.deregister("x"));
749 assert!(c.registered().is_empty());
750 }
751
752 #[test]
753 fn deregister_unknown_returns_false() {
754 let mut c = ConservationChecker::new();
755 assert!(!c.deregister("ghost"));
756 }
757
758 #[test]
761 fn reset_baseline_clears_violation() {
762 let mut c = ConservationChecker::new();
763 c.register("budget", 100.0, 0.0);
764 c.update("budget", 50.0);
765 assert!(!c.is_conserved("budget"));
766 c.reset_baseline("budget");
767 assert!(c.is_conserved("budget"));
768 assert!((c.initial_value("budget") - 50.0).abs() < f64::EPSILON);
769 }
770
771 #[test]
772 #[should_panic(expected = "not registered")]
773 fn reset_baseline_panics_on_unknown() {
774 ConservationChecker::new().reset_baseline("nope");
775 }
776
777 #[test]
780 #[should_panic(expected = "not registered")]
781 fn snapshot_count_panics_on_unknown() {
782 ConservationChecker::new().snapshot_count("nope");
783 }
784
785 #[test]
788 #[should_panic(expected = "not registered")]
789 fn current_value_panics_on_unknown() {
790 ConservationChecker::new().current_value("nope");
791 }
792
793 #[test]
794 #[should_panic(expected = "not registered")]
795 fn initial_value_panics_on_unknown() {
796 ConservationChecker::new().initial_value("nope");
797 }
798
799 #[test]
802 fn budget_tracking_scenario() {
803 let mut c = ConservationChecker::new();
804 c.register("remaining", 5000.0, 1000.0);
805
806 c.update("remaining", 4500.0);
807 c.snapshot();
808 assert!(c.is_conserved("remaining"));
809
810 c.update("remaining", 4200.0);
811 c.snapshot();
812 assert!(c.is_conserved("remaining"));
813
814 c.update("remaining", 3900.0);
815 c.snapshot();
816 assert!(!c.is_conserved("remaining"));
817 }
818
819 #[test]
820 fn token_budget_depletion() {
821 let mut c = ConservationChecker::new();
822 c.register("tokens", 1000.0, 0.0);
823
824 for _ in 0..5 {
825 c.update("tokens", c.current_value("tokens") - 100.0);
826 c.snapshot();
827 }
828 assert!(!c.is_conserved("tokens"));
829 let v = c.violations();
830 assert!(v.contains(&"tokens".to_string()));
831 }
832
833 #[test]
834 fn multiple_snapshots_drift_calculation() {
835 let mut c = ConservationChecker::new();
836 c.register("q", 0.0, 0.0);
837 for i in 1..=10 {
838 c.update("q", i as f64 * 5.0);
839 c.snapshot();
840 }
841 assert!((c.drift_rate("q") - (50.0 / 10.0)).abs() < 1e-9);
843 }
844
845 #[test]
846 fn tolerance_zero_strict() {
847 let mut c = ConservationChecker::new();
848 c.register("strict", 100.0, 0.0);
849 c.update("strict", 99.999);
850 assert!(!c.is_conserved("strict"));
851 }
852
853 #[test]
854 fn large_tolerance_never_violates() {
855 let mut c = ConservationChecker::new();
856 c.register("lenient", 100.0, 10000.0);
857 c.update("lenient", -9000.0);
858 assert!(c.is_conserved("lenient"));
859 }
860
861 #[test]
862 fn negative_values_work() {
863 let mut c = ConservationChecker::new();
864 c.register("temp", -40.0, 0.0);
865 c.update("temp", -50.0);
866 assert!(!c.is_conserved("temp"));
867 c.update("temp", -30.0);
868 assert!(c.is_conserved("temp"));
869 }
870
871 #[test]
872 fn clone_independence() {
873 let mut c = ConservationChecker::new();
874 c.register("q", 10.0, 0.0);
875 let mut c2 = c.clone();
876 c2.update("q", 5.0);
877 assert!(c.is_conserved("q"));
878 assert!(!c2.is_conserved("q"));
879 }
880
881 #[test]
882 fn snapshot_count_panics_on_unknown2() {
883 let c = ConservationChecker::new();
884 let result = std::panic::catch_unwind(|| c.snapshot_count("nope"));
885 assert!(result.is_err());
886 }
887
888 #[test]
889 fn register_with_string_ref() {
890 let mut c = ConservationChecker::new();
891 let name = "quantity";
892 c.register(name, 42.0, 1.0);
893 assert!(c.is_conserved("quantity"));
894 }
895
896 #[test]
897 fn empty_violations_after_register() {
898 let mut c = ConservationChecker::new();
899 c.register("a", 10.0, 0.0);
900 assert!(c.violations().is_empty());
901 }
902
903 #[test]
904 fn phase_stable_on_increase() {
905 let mut c = ConservationChecker::new();
906 c.register("q", 100.0, 10.0);
907 c.update("q", 105.0);
909 c.snapshot();
910 c.update("q", 110.0);
911 c.snapshot();
912 c.update("q", 115.0); c.snapshot();
914 assert_eq!(c.phase("q"), Phase::Stable);
915 }
916}