dualis_core/conserved.rs
1//! Conservation, as a thing a process must answer for rather than a property it
2//! is trusted to have.
3//!
4//! `SurfaceOptics` stores reflectance and transmittance and computes absorptance
5//! as the remainder, so a surface cannot return more light than reached it. That
6//! is the right idea and the wrong scope: it protects one quantity at one kind of
7//! boundary. Momentum in a collision, charge across a junction, mass through a
8//! pipe and energy across a coupling interface are all the same problem, and all
9//! of them are places a simulation can quietly manufacture something.
10//!
11//! So a process reports what it holds, before and after, and the difference is
12//! checked. A [`Ledger`] is that report; [`audit`] is that check; a [`Violation`]
13//! names what went missing and where.
14//!
15//! # Relative to what, exactly
16//!
17//! Floating-point arithmetic loses the low bits of every sum, so no real
18//! integrator conserves anything exactly and a test for exact equality would fail
19//! on correct code. The loss is bounded and relative — but relative to the wrong
20//! thing if one is not careful, and this is the trap:
21//!
22//! A well-formed system's conserved total is often **exactly zero**. One domain
23//! holds a debt of 28.9 J and another holds 28.9 J of asset, and the sum is nothing.
24//! Comparing the residual against that sum makes every rounding error a 100%
25//! relative error, and the audit fires on correct code.
26//!
27//! So a [`Ledger`] entry records the largest magnitude that went into it as well as
28//! the total, and [`audit`] judges the change against *that*. Rounding error scales
29//! with the size of the numbers being added, not with the size of their sum, and
30//! this is the version of the tolerance that says so.
31
32use std::collections::BTreeMap;
33use std::fmt;
34
35/// One quantity's books: the net total, and the size of the entries it came from.
36#[derive(Clone, Copy, Debug, Default, PartialEq)]
37struct Entry {
38 total: f64,
39 /// Largest single contribution, which is the scale rounding error lives on.
40 scale: f64,
41}
42
43/// What a process claims to be holding, by quantity name, in SI base units.
44///
45/// A `BTreeMap` rather than a `Vec` or a `HashMap`: names must come out in one
46/// order for the audit report to be reproducible, and a hash map's order is not
47/// one order.
48#[derive(Clone, Debug, Default, PartialEq)]
49pub struct Ledger(BTreeMap<&'static str, Entry>);
50
51/// The quantities worth naming. Strings rather than an enum, so a domain crate
52/// can add its own without editing the kernel — but these spellings are the ones
53/// [`audit`] will match across domains, so use them.
54pub mod quantity {
55 /// Joules. The channel four of the five domains publish and consume on.
56 pub const ENERGY: &str = "energy";
57 /// kg·m·s⁻¹. Audited component by component, which makes the smallest component the
58 /// binding one — see [`audit`](super::audit).
59 pub const MOMENTUM: &str = "momentum";
60 /// Kilograms.
61 pub const MASS: &str = "mass";
62 /// Coulombs.
63 pub const CHARGE: &str = "charge";
64 /// A count, not an energy. A photon budget and a joule budget are different books, and
65 /// a detector is where the two stop being interchangeable.
66 pub const PHOTONS: &str = "photons";
67}
68
69impl Ledger {
70 /// An empty ledger, holding nothing.
71 pub fn new() -> Ledger {
72 Ledger(BTreeMap::new())
73 }
74
75 /// Record a total. Repeating a name adds to it, since a domain made of parts
76 /// reports the sum of its parts.
77 pub fn with(mut self, quantity: &'static str, si_total: f64) -> Ledger {
78 self.add(quantity, si_total);
79 self
80 }
81
82 /// Add to a quantity's total, in SI base units.
83 ///
84 /// Also raises that entry's `scale` to the largest contribution seen, which is what makes
85 /// a relative tolerance mean anything when the net total is near zero.
86 pub fn add(&mut self, quantity: &'static str, si_total: f64) {
87 let entry = self.0.entry(quantity).or_default();
88 entry.total += si_total;
89 entry.scale = entry.scale.max(si_total.abs());
90 }
91
92 /// The net total for a quantity.
93 pub fn get(&self, quantity: &str) -> Option<f64> {
94 self.0.get(quantity).map(|e| e.total)
95 }
96
97 /// The largest single entry that went into a quantity — the scale on which
98 /// rounding error in its total should be judged.
99 pub fn scale_of(&self, quantity: &str) -> Option<f64> {
100 self.0.get(quantity).map(|e| e.scale)
101 }
102
103 /// Whether anything at all has been recorded.
104 pub fn is_empty(&self) -> bool {
105 self.0.is_empty()
106 }
107
108 /// Names and net totals, in a fixed order.
109 pub fn quantities(&self) -> impl Iterator<Item = (&'static str, f64)> + '_ {
110 self.0.iter().map(|(k, e)| (*k, e.total))
111 }
112
113 /// Sum of two ledgers — how a simulation totals its domains. The scales carry
114 /// over as the larger of the two, so a big domain's rounding budget is not
115 /// shrunk by being added to a small one.
116 pub fn merged(mut self, other: &Ledger) -> Ledger {
117 for (name, entry) in other.0.iter() {
118 let mine = self.0.entry(name).or_default();
119 mine.total += entry.total;
120 mine.scale = mine.scale.max(entry.scale);
121 }
122 self
123 }
124}
125
126/// A conservation law that did not hold.
127#[derive(Clone, Debug, PartialEq)]
128pub struct Violation {
129 /// Which law: one of [`quantity`], or a domain's own name for it.
130 pub quantity: String,
131 /// Where it broke — a domain name, a coupling name, a wavelength.
132 pub site: String,
133 /// What the quantity was, in SI base units.
134 pub before: f64,
135 /// What it became.
136 pub after: f64,
137 /// What the discrepancy was measured against — the largest entry that went into
138 /// the books, not the net total, since a correct system's net is often zero. Zero
139 /// means "use the totals", which is what a non-conservation error does.
140 pub scale: f64,
141 /// The tolerance that was being applied, so the report says how badly.
142 pub tolerance: f64,
143}
144
145impl Violation {
146 /// For the cases that are not a before/after comparison at all: a surface
147 /// specified to reflect more than it receives, an iteration that never
148 /// converged.
149 pub fn at(site: impl Into<String>, quantity: impl Into<String>, detail: f64) -> Violation {
150 Violation {
151 quantity: quantity.into(),
152 site: site.into(),
153 before: detail,
154 after: detail,
155 scale: detail.abs(),
156 tolerance: 0.0,
157 }
158 }
159
160 /// Absolute size of the discrepancy.
161 pub fn error(&self) -> f64 {
162 (self.after - self.before).abs()
163 }
164
165 /// Discrepancy as a fraction of the scale it was judged against.
166 pub fn relative_error(&self) -> f64 {
167 let scale = if self.scale > 0.0 {
168 self.scale
169 } else {
170 self.before.abs().max(self.after.abs())
171 };
172 if scale == 0.0 {
173 0.0
174 } else {
175 self.error() / scale
176 }
177 }
178}
179
180impl fmt::Display for Violation {
181 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 // `Violation::at` builds the cases that are not a before/after comparison — a surface
183 // specified to reflect more than it receives, a substance with no heat capacity, an
184 // iteration that never converged. Those carry a *message* in `quantity`, not a
185 // quantity, and reading it as one produced the first error a consumer ever saw from
186 // this library: "substance has no heat capacity is not conserved at plate: inf".
187 if self.tolerance == 0.0 && self.before == self.after {
188 return write!(f, "at {}: {} ({})", self.site, self.quantity, self.before);
189 }
190 if self.before == self.after {
191 return write!(
192 f,
193 "{} is not conserved at {}: {}",
194 self.quantity, self.site, self.before
195 );
196 }
197 let verb = if self.after > self.before {
198 "created"
199 } else {
200 "destroyed"
201 };
202 write!(
203 f,
204 "{} {} at {}: {:.6e} became {:.6e}, a relative change of {:.3e} against a \
205 tolerance of {:.3e}",
206 self.quantity,
207 verb,
208 self.site,
209 self.before,
210 self.after,
211 self.relative_error(),
212 self.tolerance
213 )
214 }
215}
216
217impl std::error::Error for Violation {}
218
219/// Compare two ledgers and name the first quantity that moved by more than
220/// `rel_tol`, in the fixed order the ledger keeps its names.
221///
222/// The change is measured against the largest of the two totals *and* the largest
223/// single entry either ledger recorded. See the module docs for why the totals alone
224/// are not enough: a correct system whose books cancel to zero would otherwise turn
225/// every rounding error into a 100% relative error.
226///
227/// Quantities present in only one of the two are treated as having been zero in
228/// the other, so a process that starts reporting momentum halfway through gets
229/// caught rather than excused.
230pub fn audit(site: &str, before: &Ledger, after: &Ledger, rel_tol: f64) -> Result<(), Violation> {
231 let mut names: Vec<&'static str> = before.0.keys().copied().collect();
232 for name in after.0.keys() {
233 if !before.0.contains_key(name) {
234 names.push(name);
235 }
236 }
237 names.sort_unstable();
238
239 for name in names {
240 let b = before.get(name).unwrap_or(0.0);
241 let a = after.get(name).unwrap_or(0.0);
242 let scale = b
243 .abs()
244 .max(a.abs())
245 .max(before.scale_of(name).unwrap_or(0.0))
246 .max(after.scale_of(name).unwrap_or(0.0));
247 // Two numbers that are both denormal are equal for every purpose a
248 // simulation has.
249 if scale < 1e-300 {
250 continue;
251 }
252 if (a - b).abs() / scale > rel_tol {
253 return Err(Violation {
254 quantity: name.to_string(),
255 site: site.to_string(),
256 before: b,
257 after: a,
258 scale,
259 tolerance: rel_tol,
260 });
261 }
262 }
263 Ok(())
264}
265
266/// Something that can say what it is holding. Implemented by domains, and by
267/// anything else whose books are worth checking.
268pub trait Conserves {
269 /// What this is currently holding.
270 fn ledger(&self) -> Ledger;
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn a_ledger_that_did_not_move_passes() {
279 let before = Ledger::new()
280 .with(quantity::ENERGY, 3.7)
281 .with(quantity::MASS, 2.0);
282 // Losing the last bits of a double is arithmetic, not a leak.
283 let after = Ledger::new()
284 .with(quantity::ENERGY, 3.7 + 4e-16)
285 .with(quantity::MASS, 2.0);
286 assert!(audit("test", &before, &after, 1e-12).is_ok());
287 }
288
289 /// The failure is named, sited and quantified, because "conservation failed"
290 /// is not a debuggable message.
291 #[test]
292 fn a_leak_is_named_and_sited() {
293 let before = Ledger::new().with(quantity::ENERGY, 1.0);
294 let after = Ledger::new().with(quantity::ENERGY, 0.6);
295 let err = audit("thermal", &before, &after, 1e-9).expect_err("40% is not arithmetic");
296 assert_eq!(err.quantity, "energy");
297 assert_eq!(err.site, "thermal");
298 assert!((err.relative_error() - 0.4).abs() < 1e-12);
299 let text = err.to_string();
300 assert!(text.contains("destroyed"), "{text}");
301 assert!(text.contains("thermal"), "{text}");
302 }
303
304 #[test]
305 fn creating_something_reads_differently_from_losing_it() {
306 let before = Ledger::new().with(quantity::PHOTONS, 1e6);
307 let after = Ledger::new().with(quantity::PHOTONS, 1.5e6);
308 let err = audit("optics", &before, &after, 1e-6).unwrap_err();
309 assert!(err.to_string().contains("created"), "{err}");
310 }
311
312 /// A quantity that appears out of nowhere is a violation, not an exemption —
313 /// this is the case a naive "compare the keys they share" audit would miss.
314 #[test]
315 fn a_quantity_absent_before_is_still_audited() {
316 let before = Ledger::new().with(quantity::ENERGY, 1.0);
317 let after = Ledger::new()
318 .with(quantity::ENERGY, 1.0)
319 .with(quantity::MOMENTUM, 5.0);
320 let err = audit("contact", &before, &after, 1e-9).expect_err("momentum from nowhere");
321 assert_eq!(err.quantity, "momentum");
322 assert_eq!(err.before, 0.0);
323 }
324
325 /// Reports come out in one order, so a failing run names the same quantity
326 /// every time rather than whichever one the hash landed on first.
327 #[test]
328 fn the_audit_order_is_fixed() {
329 let before = Ledger::new()
330 .with(quantity::MOMENTUM, 1.0)
331 .with(quantity::CHARGE, 1.0)
332 .with(quantity::ENERGY, 1.0);
333 let after = Ledger::new()
334 .with(quantity::MOMENTUM, 2.0)
335 .with(quantity::CHARGE, 2.0)
336 .with(quantity::ENERGY, 2.0);
337 // Three laws broken at once; the alphabetically first is reported, every
338 // time, on every platform.
339 for _ in 0..8 {
340 let err = audit("s", &before, &after, 1e-9).unwrap_err();
341 assert_eq!(err.quantity, "charge");
342 }
343 }
344
345 #[test]
346 fn ledgers_merge_by_summing() {
347 let a = Ledger::new().with(quantity::ENERGY, 1.5);
348 let b = Ledger::new()
349 .with(quantity::ENERGY, 2.5)
350 .with(quantity::MASS, 1.0);
351 let total = a.merged(&b);
352 assert_eq!(total.get(quantity::ENERGY), Some(4.0));
353 assert_eq!(total.get(quantity::MASS), Some(1.0));
354 }
355
356 /// Zero against zero is not a hundred-percent error.
357 #[test]
358 fn nothing_compared_to_nothing_is_fine() {
359 let z = Ledger::new().with(quantity::ENERGY, 0.0);
360 assert!(audit("s", &z, &z, 0.0).is_ok());
361 }
362}