Skip to main content

conservation_checker/
lib.rs

1#![deny(unsafe_code)]
2
3#[cfg(feature = "serde")]
4use serde::{Serialize, Deserialize};
5
6use std::collections::HashMap;
7
8/// Phase detected from a quantity's rate-of-change history.
9///
10/// Each variant describes the trajectory of a tracked quantity relative
11/// to its conservation boundary (initial value minus tolerance).
12///
13/// # Transitions
14///
15/// ```text
16/// Stable → PreTransition → Transitioning → Resolving → Stable
17/// ```
18///
19/// Use [`ConservationChecker::phase`] to obtain the current phase.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
22pub enum Phase {
23    /// Rate of change is near zero — the quantity is holding steady.
24    Stable,
25    /// Rate of change is accelerating but hasn't crossed the tolerance threshold.
26    ///
27    /// This is an early warning: the quantity is still conserved, but its
28    /// velocity is increasing and may lead to a violation.
29    PreTransition,
30    /// Value is actively decreasing beyond tolerance — the conservation law is violated.
31    Transitioning,
32    /// Value was decreasing but is now recovering toward the baseline.
33    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/// Tracker for one-sided conservation laws.
57///
58/// A `ConservationChecker` monitors named quantities that must not decrease
59/// (beyond an optional tolerance). It records snapshots over time so you can
60/// detect drift, phase transitions, and violations.
61#[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    /// Create a new, empty tracker.
75    ///
76    /// # Example
77    ///
78    /// ```
79    /// use conservation_checker::ConservationChecker;
80    ///
81    /// let checker = ConservationChecker::new();
82    /// assert!(checker.registered().is_empty());
83    /// ```
84    pub fn new() -> Self {
85        Self {
86            quantities: HashMap::new(),
87        }
88    }
89
90    /// Register a named quantity with an initial value and tolerance.
91    ///
92    /// `tolerance` is the maximum allowed decrease from the initial value
93    /// before the quantity is considered *violated*. Use `0.0` for strict
94    /// conservation.
95    ///
96    /// # Example
97    ///
98    /// ```
99    /// use conservation_checker::ConservationChecker;
100    ///
101    /// let mut checker = ConservationChecker::new();
102    /// checker.register("energy", 100.0, 5.0);  // allow up to 5.0 decrease
103    /// checker.register("budget", 1000.0, 0.0); // strict: any decrease violates
104    ///
105    /// assert!(checker.is_conserved("energy"));
106    /// assert!(checker.is_conserved("budget"));
107    /// ```
108    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    /// Update the current value of a registered quantity.
122    ///
123    /// Call this whenever the tracked quantity changes. After updating, use
124    /// [`is_conserved`](Self::is_conserved) to check whether the new value
125    /// is still within tolerance.
126    ///
127    /// # Panics
128    ///
129    /// Panics if `name` has not been registered.
130    ///
131    /// # Example
132    ///
133    /// ```
134    /// use conservation_checker::ConservationChecker;
135    ///
136    /// let mut checker = ConservationChecker::new();
137    /// checker.register("tokens", 100.0, 0.0);
138    /// checker.update("tokens", 80.0);
139    ///
140    /// assert!(!checker.is_conserved("tokens"));
141    /// ```
142    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    /// Check whether a quantity is still conserved.
151    ///
152    /// A quantity is conserved when `current >= initial - tolerance`.
153    /// Increases are always OK (one-sided conservation).
154    ///
155    /// # Panics
156    ///
157    /// Panics if `name` has not been registered.
158    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    /// Return the names of all quantities that are currently violated.
167    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    /// Record a snapshot of every quantity's current value into its history.
176    ///
177    /// Call this periodically (e.g. once per tick, request, or batch) to
178    /// build a time-series that [`phase`](Self::phase) and
179    /// [`drift_rate`](Self::drift_rate) can analyse.
180    ///
181    /// # Example
182    ///
183    /// ```
184    /// use conservation_checker::ConservationChecker;
185    ///
186    /// let mut checker = ConservationChecker::new();
187    /// checker.register("budget", 500.0, 100.0);
188    ///
189    /// checker.update("budget", 450.0);
190    /// checker.snapshot();
191    ///
192    /// checker.update("budget", 420.0);
193    /// checker.snapshot();
194    ///
195    /// // drift_rate now compares first and last snapshot
196    /// let drift = checker.drift_rate("budget");
197    /// assert!(drift < 0.0); // budget is drifting downward
198    /// ```
199    pub fn snapshot(&mut self) {
200        for state in self.quantities.values_mut() {
201            state.history.push(state.current);
202        }
203    }
204
205    /// Detect the current phase of a quantity based on its history.
206    ///
207    /// Uses the most recent snapshots to compute a short-term rate of change
208    /// and classifies the trajectory as [`Phase::Stable`], [`Phase::PreTransition`],
209    /// [`Phase::Transitioning`], or [`Phase::Resolving`].
210    ///
211    /// Requires at least 3 snapshots to return anything other than `Stable`.
212    ///
213    /// # Panics
214    ///
215    /// Panics if `name` has not been registered.
216    ///
217    /// # Example
218    ///
219    /// ```
220    /// use conservation_checker::{ConservationChecker, Phase};
221    ///
222    /// let mut checker = ConservationChecker::new();
223    /// checker.register("energy", 100.0, 0.0);
224    ///
225    /// // Drain it down
226    /// checker.update("energy", 90.0);
227    /// checker.snapshot();
228    /// checker.update("energy", 80.0);
229    /// checker.snapshot();
230    ///
231    /// assert_eq!(checker.phase("energy"), Phase::Transitioning);
232    /// ```
233    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        // Recent per-step rate (last two snapshots)
250        let recent_rate = if len >= 2 {
251            state.history[len - 1] - state.history[len - 2]
252        } else {
253            0.0
254        };
255
256        // Older per-step rate (two snapshots before the recent pair)
257        let older_rate = if len >= 4 {
258            state.history[len - 3] - state.history[len - 4]
259        } else {
260            0.0
261        };
262
263        // Use a small absolute threshold so we don't classify tiny fluctuations
264        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    /// Compute the average rate of change per snapshot for a quantity.
279    ///
280    /// Computed as `(last_value - first_value) / (snapshot_count - 1)`.
281    /// Returns `0.0` when there are fewer than two snapshots.
282    ///
283    /// # Panics
284    ///
285    /// Panics if `name` has not been registered.
286    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        // Simple: (last - first) / (n - 1)
298        (state.history.last().unwrap() - state.history.first().unwrap()) / (n - 1.0)
299    }
300
301    /// Get the current value of a quantity.
302    ///
303    /// # Panics
304    ///
305    /// Panics if `name` has not been registered.
306    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    /// Get the initial value the quantity was registered with.
315    ///
316    /// # Panics
317    ///
318    /// Panics if `name` has not been registered.
319    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    /// Get the number of snapshots recorded for a quantity (including the initial value).
328    ///
329    /// # Panics
330    ///
331    /// Panics if `name` has not been registered.
332    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    /// List all registered quantity names in arbitrary order.
341    pub fn registered(&self) -> Vec<String> {
342        self.quantities.keys().cloned().collect()
343    }
344
345    /// Remove a quantity from the tracker.
346    ///
347    /// Returns `true` if the quantity existed and was removed, `false` otherwise.
348    pub fn deregister(&mut self, name: &str) -> bool {
349        self.quantities.remove(name).is_some()
350    }
351
352    /// Reset a quantity's initial value to its current value, clearing any violations.
353    ///
354    /// Useful after resolving a violation to establish a new baseline without
355    /// re-registering the quantity.
356    ///
357    /// # Panics
358    ///
359    /// Panics if `name` has not been registered.
360    ///
361    /// # Example
362    ///
363    /// ```
364    /// use conservation_checker::ConservationChecker;
365    ///
366    /// let mut checker = ConservationChecker::new();
367    /// checker.register("budget", 100.0, 0.0);
368    /// checker.update("budget", 50.0);
369    ///
370    /// assert!(!checker.is_conserved("budget"));
371    ///
372    /// checker.reset_baseline("budget");
373    /// assert!(checker.is_conserved("budget"));
374    /// assert_eq!(checker.initial_value("budget"), 50.0);
375    /// ```
376    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    /// Serialize a snapshot of all quantities to a JSON string.
385    ///
386    /// Produces a JSON object with each quantity's name, current value,
387    /// initial value, tolerance, and conserved status. Useful for
388    /// Prometheus-style export or logging.
389    ///
390    /// Requires the `serde` feature.
391    #[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    // ── Construction & registration ──────────────────────────────────
416
417    #[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    // ── Updates ──────────────────────────────────────────────────────
468
469    #[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    // ── Conservation checks ──────────────────────────────────────────
501
502    #[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); // initial - tolerance = 95
538        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    // ── Violations ───────────────────────────────────────────────────
556
557    #[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    // ── Snapshots & history ──────────────────────────────────────────
589
590    #[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); // initial counts
595        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        // drift_rate should now be (90 - 100) / 1 = -10
608        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    // ── Drift rate ───────────────────────────────────────────────────
622
623    #[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        // (20 - 10) / 1 = 10
637        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        // (90 - 100) / 1 = -10
647        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        // 0 -> 10 -> 20 -> 30  (3 snapshots after initial)
655        for v in [10.0, 20.0, 30.0] {
656            c.update("q", v);
657            c.snapshot();
658        }
659        // (30 - 0) / (4-1) = 10
660        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    // ── Phase detection ──────────────────────────────────────────────
670
671    #[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        // Drop below tolerance
696        c.update("q", 90.0);
697        c.snapshot();
698        c.update("q", 80.0);
699        c.snapshot();
700        // Now increase — still violated (92 < 95) but recovering
701        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); // large tolerance
710        c.update("q", 99.0);
711        c.snapshot();
712        c.update("q", 96.0); // accelerating downward (-3 vs -1)
713        c.snapshot();
714        // Accelerating downward but not yet violated (96 > 50)
715        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        // Only 1 history entry (the initial)
723        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    // ── Phase display ────────────────────────────────────────────────
733
734    #[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    // ── Deregister ───────────────────────────────────────────────────
743
744    #[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    // ── Reset baseline ───────────────────────────────────────────────
759
760    #[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    // ── Snapshot count ───────────────────────────────────────────────
778
779    #[test]
780    #[should_panic(expected = "not registered")]
781    fn snapshot_count_panics_on_unknown() {
782        ConservationChecker::new().snapshot_count("nope");
783    }
784
785    // ── Current / initial value ──────────────────────────────────────
786
787    #[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    // ── Integration-style tests ──────────────────────────────────────
800
801    #[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        // first=0, last=50, n=11 entries
842        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        // Constant rate of increase across 4+ snapshots
908        c.update("q", 105.0);
909        c.snapshot();
910        c.update("q", 110.0);
911        c.snapshot();
912        c.update("q", 115.0); // same +5 rate
913        c.snapshot();
914        assert_eq!(c.phase("q"), Phase::Stable);
915    }
916}