Skip to main content

noether_guard/
lib.rs

1//! # noether-guard
2//!
3//! A static analysis tool for physics simulations that verifies conservation laws.
4//!
5//! Noether's theorem states that every continuous symmetry implies a conserved quantity:
6//! - **Time translation symmetry** → energy conservation
7//! - **Spatial translation symmetry** → momentum conservation
8//! - **Rotational symmetry** → angular momentum conservation
9//!
10//! NoetherGuard instruments simulation code to check these at runtime and pinpoint
11//! where they break, using renormalization-group-style scale analysis.
12//!
13//! # Quick start
14//!
15//! ```
16//! use noether_guard::ConservationMonitor;
17//!
18//! let mut monitor = ConservationMonitor::newtonian(0.01);
19//! // tick with [energy, px, py, pz, lx, ly, lz]
20//! monitor.tick(0.0, &[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]).unwrap();
21//! monitor.tick(0.1, &[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]).unwrap();
22//! let report = monitor.report();
23//! println!("{report}");
24//! ```
25
26pub mod conservation;
27pub mod drift;
28pub mod error;
29pub mod monitor;
30pub mod renormalize;
31pub mod report;
32pub mod symmetry;
33
34pub use conservation::{ConservationLaw, ConservedQuantity};
35pub use drift::DriftDetector;
36pub use error::{Error, Result};
37pub use monitor::{ConservationMonitor, MonitorSnapshot};
38pub use renormalize::RenormalizationGroup;
39pub use report::{Report, Violation};
40pub use symmetry::{Symmetry, SymmetryGroup};
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45
46    // ---- Symmetry tests ----
47
48    #[test]
49    fn symmetry_conserved_quantity_names() {
50        assert_eq!(Symmetry::TimeTranslation.conserved_quantity(), "Energy");
51        assert_eq!(Symmetry::SpatialTranslation.conserved_quantity(), "Momentum");
52        assert_eq!(Symmetry::Rotation.conserved_quantity(), "Angular Momentum");
53    }
54
55    #[test]
56    fn symmetry_component_counts() {
57        assert_eq!(Symmetry::TimeTranslation.component_count(), 1);
58        assert_eq!(Symmetry::SpatialTranslation.component_count(), 3);
59        assert_eq!(Symmetry::Rotation.component_count(), 3);
60    }
61
62    #[test]
63    fn gauge_symmetry() {
64        let g = SymmetryGroup {
65            name: "Charge".to_string(),
66            generators: 1,
67        };
68        let sym = Symmetry::Gauge(g);
69        assert_eq!(sym.conserved_quantity(), "Charge");
70        assert_eq!(sym.component_count(), 1);
71    }
72
73    #[test]
74    fn symmetry_display() {
75        assert!(format!("{}", Symmetry::TimeTranslation).contains("Energy"));
76    }
77
78    // ---- ConservationLaw tests ----
79
80    #[test]
81    fn conservation_law_is_conserved() {
82        let law = ConservationLaw::new(Symmetry::TimeTranslation, 100.0, 0.1);
83        assert!(law.is_conserved(100.05));
84        assert!(!law.is_conserved(100.2));
85    }
86
87    #[test]
88    fn conservation_law_drift() {
89        let law = ConservationLaw::new(Symmetry::TimeTranslation, 10.0, 1.0);
90        assert!((law.drift(12.0) - 2.0).abs() < 1e-10);
91    }
92
93    #[test]
94    fn conservation_law_drift_rate() {
95        let law = ConservationLaw::new(Symmetry::TimeTranslation, 10.0, 1.0);
96        let rate = law.drift_rate(10.0, 1.0);
97        assert!((rate - 0.0).abs() < 1e-10);
98    }
99
100    // ---- ConservedQuantity tests ----
101
102    #[test]
103    fn conserved_quantity_record_and_drift() {
104        let mut q = ConservedQuantity::new("Energy");
105        q.record(0.0, 100.0);
106        q.record(1.0, 101.0);
107        q.record(2.0, 103.0);
108        assert!((q.total_drift() - 3.0).abs() < 1e-10);
109        assert!((q.current_drift_rate() - 2.0).abs() < 1e-10);
110        assert_eq!(q.len(), 3);
111    }
112
113    #[test]
114    fn conserved_quantity_empty() {
115        let q = ConservedQuantity::new("Energy");
116        assert!(q.is_empty());
117        assert_eq!(q.total_drift(), 0.0);
118    }
119
120    // ---- ConservationMonitor tests ----
121
122    #[test]
123    fn monitor_newtonian_expected_values() {
124        let m = ConservationMonitor::newtonian(0.01);
125        // 1 + 3 + 3 = 7
126        assert_eq!(m.expected_value_count(), 7);
127    }
128
129    #[test]
130    fn monitor_hamiltonian_expected_values() {
131        let m = ConservationMonitor::hamiltonian(0.01);
132        assert_eq!(m.expected_value_count(), 1);
133    }
134
135    #[test]
136    fn monitor_tick_clean() {
137        let mut m = ConservationMonitor::hamiltonian(0.1);
138        m.laws[0].initial_value = 100.0;
139        m.tick(0.0, &[100.0]).unwrap();
140        m.tick(1.0, &[100.05]).unwrap();
141        assert_eq!(m.history.len(), 2);
142        assert!(m.history.iter().all(|s| s.violations.is_empty()));
143        assert!((m.health() - 1.0).abs() < 1e-10);
144    }
145
146    #[test]
147    fn monitor_tick_violation() {
148        let mut m = ConservationMonitor::hamiltonian(0.1);
149        m.laws[0].initial_value = 100.0;
150        m.tick(0.0, &[100.0]).unwrap();
151        m.tick(1.0, &[120.0]).unwrap();
152        assert_eq!(m.history[1].violations, vec![0]);
153        assert!((m.health() - 0.5).abs() < 1e-10);
154    }
155
156    #[test]
157    fn monitor_value_mismatch() {
158        let mut m = ConservationMonitor::hamiltonian(0.1);
159        let result = m.tick(0.0, &[1.0, 2.0]);
160        assert!(result.is_err());
161    }
162
163    #[test]
164    fn monitor_custom() {
165        let laws = vec![ConservationLaw::new(Symmetry::TimeTranslation, 50.0, 0.5)];
166        let m = ConservationMonitor::custom(laws);
167        assert_eq!(m.expected_value_count(), 1);
168        assert_eq!(m.laws.len(), 1);
169    }
170
171    #[test]
172    fn monitor_add_law() {
173        let mut m = ConservationMonitor::new();
174        m.add_law(ConservationLaw::new(Symmetry::TimeTranslation, 0.0, 0.01));
175        m.add_law(ConservationLaw::new(Symmetry::SpatialTranslation, 0.0, 0.01));
176        assert_eq!(m.expected_value_count(), 4);
177    }
178
179    #[test]
180    fn monitor_default() {
181        let m = ConservationMonitor::default();
182        assert_eq!(m.laws.len(), 0);
183    }
184
185    // ---- DriftDetector tests ----
186
187    #[test]
188    fn drift_detector_no_drift() {
189        let mut d = DriftDetector::new(0, 1.0);
190        d.record_drift(0.0, 0.1);
191        d.record_drift(1.0, 0.2);
192        assert!(!d.is_drifting());
193        assert_eq!(d.sample_count(), 2);
194    }
195
196    #[test]
197    fn drift_detector_with_drift() {
198        let mut d = DriftDetector::new(0, 0.5);
199        d.record_drift(0.0, 0.1);
200        d.record_drift(1.0, 0.6);
201        assert!(d.is_drifting());
202        assert!((d.max_drift() - 0.6).abs() < 1e-10);
203    }
204
205    #[test]
206    fn drift_detector_first_violation() {
207        let mut d = DriftDetector::new(0, 0.5);
208        d.record_drift(0.0, 0.1);
209        d.record_drift(1.0, 0.6);
210        assert!((d.first_violation_time().unwrap() - 1.0).abs() < 1e-10);
211    }
212
213    // ---- RenormalizationGroup tests ----
214
215    #[test]
216    fn rg_coarse_grain() {
217        let data: Vec<(f64, f64)> = (0..10)
218            .map(|i| (i as f64, (i % 2) as f64))
219            .collect();
220        let rg = RenormalizationGroup::new(&data);
221        let coarse = rg.coarse_grain(2);
222        assert_eq!(coarse.len(), 5);
223        // Each pair averaged: (0+1)/2=0.5, (1+0)/2=0.5, etc.
224        assert!((coarse[0].1 - 0.5).abs() < 1e-10);
225    }
226
227    #[test]
228    fn rg_coarse_grain_empty() {
229        let rg = RenormalizationGroup::new(&[]);
230        let coarse = rg.coarse_grain(2);
231        assert!(coarse.is_empty());
232    }
233
234    #[test]
235    fn rg_find_breaking_scale() {
236        // Monotonically increasing drift
237        let data: Vec<(f64, f64)> = (0..20).map(|i| (i as f64, i as f64 * 0.1)).collect();
238        let rg = RenormalizationGroup::new(&data);
239        let scale = rg.find_breaking_scale();
240        assert!(scale.is_some());
241    }
242
243    #[test]
244    fn rg_no_breaking_constant() {
245        let data: Vec<(f64, f64)> = (0..20).map(|i| (i as f64, 0.0)).collect();
246        let rg = RenormalizationGroup::new(&data);
247        let scale = rg.find_breaking_scale();
248        // Constant zero drift — no breaking
249        assert!(scale.is_none());
250    }
251
252    #[test]
253    fn rg_beta_function() {
254        let data: Vec<(f64, f64)> = (0..10).map(|i| (i as f64, i as f64)).collect();
255        let rg = RenormalizationGroup::new(&data);
256        let beta = rg.beta_function(&[1, 2, 4]);
257        assert_eq!(beta.len(), 3);
258    }
259
260    // ---- Report tests ----
261
262    #[test]
263    fn report_text_clean() {
264        let report = Report {
265            total_ticks: 100,
266            health: 1.0,
267            total_violations: 0,
268            violations: vec![],
269        };
270        let text = report.to_text();
271        assert!(text.contains("satisfied"));
272    }
273
274    #[test]
275    fn report_text_with_violations() {
276        let report = Report {
277            total_ticks: 100,
278            health: 0.9,
279            total_violations: 10,
280            violations: vec![Violation {
281                law_name: "Energy".to_string(),
282                law_index: 0,
283                first_violation: Some(5.0),
284                last_violation: Some(50.0),
285                violation_count: 10,
286                breaking_scale: Some(0.25),
287            }],
288        };
289        let text = report.to_text();
290        assert!(text.contains("Energy"));
291        assert!(text.contains("90.0%"));
292    }
293
294    #[test]
295    fn report_json_roundtrip() {
296        let report = Report {
297            total_ticks: 50,
298            health: 1.0,
299            total_violations: 0,
300            violations: vec![],
301        };
302        let json = report.to_json();
303        let parsed: Report = serde_json::from_str(&json).unwrap();
304        assert_eq!(parsed.total_ticks, 50);
305    }
306
307    // ---- Integration test: double pendulum-like scenario ----
308
309    #[test]
310    fn double_pendulum_energy_drift() {
311        // Simulate a simple harmonic oscillator with Euler method (which drifts)
312        let mut monitor = ConservationMonitor::hamiltonian(0.001);
313        let dt = 0.01;
314        let mut x = 1.0_f64;
315        let mut v = 0.0_f64;
316        let omega = 2.0_f64;
317
318        // Initial energy
319        let e0 = 0.5 * v * v + 0.5 * omega * omega * x * x;
320        monitor.laws[0].initial_value = e0;
321
322        for i in 0..500 {
323            let t = i as f64 * dt;
324            // Euler step (energy-drifting)
325            v += -omega * omega * x * dt;
326            x += v * dt;
327            let energy = 0.5 * v * v + 0.5 * omega * omega * x * x;
328            monitor.tick(t, &[energy]).unwrap();
329        }
330
331        let report = monitor.report();
332        // Euler method on SHO should drift significantly
333        assert!(report.total_violations > 0, "Euler method should violate energy conservation");
334    }
335
336    #[test]
337    fn newtonian_system_conserved() {
338        let mut monitor = ConservationMonitor::newtonian(0.1);
339        // Set initial values to match what we'll feed
340        monitor.laws[0].initial_value = 10.0; // energy
341        monitor.laws[1].initial_value = 1.0;  // momentum (x)
342        monitor.laws[2].initial_value = 0.5;  // angular momentum (x)
343        // Perfect conservation: all values constant
344        for i in 0..100 {
345            let t = i as f64 * 0.01;
346            monitor.tick(t, &[10.0, 1.0, 0.0, 0.0, 0.5, 0.0, 0.0]).unwrap();
347        }
348        assert!((monitor.health() - 1.0).abs() < 1e-10);
349        assert_eq!(monitor.total_violations(), 0);
350    }
351}