Skip to main content

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}