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 six 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.
230/// What each conserved quantity is allowed to drift by, relatively, across a step.
231///
232/// # Why this is not one number
233///
234/// It was, and the reason it stopped being is worth stating: **the loosest quantity in a
235/// simulation was setting what every other one was checked against.** A Barnes-Hut N-body gives
236/// up exact momentum by construction — each body sees its own approximation of the rest, so their
237/// mutual forces no longer cancel — and the drift is a knob set by the opening angle, worth
238/// perhaps `1e-6`. Energy in a rigid room is exact to `1e-15`. Run both in one simulation under a
239/// single number and either the momentum check refuses a correct run or the energy check stops
240/// being able to see anything.
241///
242/// A quantity's achievable accuracy is a property of the *scheme* that carries it, and different
243/// quantities in one simulation are carried by different schemes. So it is a number per quantity,
244/// with a default for the ones nobody has thought about.
245///
246/// # What this does not fix
247///
248/// Two domains holding the **same** quantity to different accuracies. A molecular fluid under a
249/// thermostat and an acoustic room both hold `energy`, and the audit sums their ledgers before
250/// comparing — so a small domain's leak is invisible against a large domain's total, whatever
251/// tolerance is set. That needs per-domain attribution rather than a per-quantity number, and it
252/// is a different and harder change: it requires knowing which domain took what from the bus.
253/// Recorded in `ARCHITECTURE.md` rather than half-done here.
254#[derive(Clone, Debug, PartialEq)]
255pub struct Tolerances {
256 default: f64,
257 /// Sorted, so iteration order is fixed and a violation's message does not depend on
258 /// insertion order. A `HashMap` here would be a determinism bug.
259 per_quantity: BTreeMap<&'static str, f64>,
260}
261
262impl Tolerances {
263 /// The same number for every quantity — what a simulation has until it says otherwise.
264 pub fn uniform(tol: f64) -> Tolerances {
265 Tolerances {
266 default: tol,
267 per_quantity: BTreeMap::new(),
268 }
269 }
270
271 /// Override one quantity.
272 ///
273 /// Takes `&'static str` because a quantity name is a compile-time fact — `quantity::ENERGY`
274 /// — and not something read from a file. Two spellings of the same channel are two channels,
275 /// which is a mistake worth making impossible rather than catching.
276 pub fn with(mut self, quantity: &'static str, tol: f64) -> Tolerances {
277 self.per_quantity.insert(quantity, tol);
278 self
279 }
280
281 /// What applies to this quantity.
282 pub fn for_quantity(&self, quantity: &str) -> f64 {
283 self.per_quantity
284 .get(quantity)
285 .copied()
286 .unwrap_or(self.default)
287 }
288
289 /// What applies to a quantity nobody has named.
290 pub fn default_tolerance(&self) -> f64 {
291 self.default
292 }
293
294 /// Every override, in name order.
295 ///
296 /// Public so a report can say what a run was actually checked against. A tolerance nobody can
297 /// read is a tolerance nobody can question.
298 pub fn overrides(&self) -> impl Iterator<Item = (&'static str, f64)> + '_ {
299 self.per_quantity.iter().map(|(k, v)| (*k, *v))
300 }
301}
302
303impl Default for Tolerances {
304 /// `1e-9` everywhere, which is what a single-number simulation used to default to.
305 fn default() -> Tolerances {
306 Tolerances::uniform(1e-9)
307 }
308}
309
310/// Audit with one tolerance for every quantity.
311///
312/// The uniform case, kept because most simulations are one and because changing this signature
313/// would break every caller for the sake of an argument they would pass a constant to.
314pub fn audit(site: &str, before: &Ledger, after: &Ledger, rel_tol: f64) -> Result<(), Violation> {
315 audit_with(site, before, after, &Tolerances::uniform(rel_tol))
316}
317
318/// Audit with a tolerance per quantity.
319///
320/// Identical to [`audit`] except for where the tolerance comes from — and the [`Violation`] it
321/// produces carries the tolerance that actually applied, so a reader is never left working out
322/// which number a failure was measured against.
323pub fn audit_with(
324 site: &str,
325 before: &Ledger,
326 after: &Ledger,
327 tolerances: &Tolerances,
328) -> Result<(), Violation> {
329 let mut names: Vec<&'static str> = before.0.keys().copied().collect();
330 for name in after.0.keys() {
331 if !before.0.contains_key(name) {
332 names.push(name);
333 }
334 }
335 names.sort_unstable();
336
337 for name in names {
338 let b = before.get(name).unwrap_or(0.0);
339 let a = after.get(name).unwrap_or(0.0);
340 let scale = b
341 .abs()
342 .max(a.abs())
343 .max(before.scale_of(name).unwrap_or(0.0))
344 .max(after.scale_of(name).unwrap_or(0.0));
345 // Two numbers that are both denormal are equal for every purpose a
346 // simulation has.
347 if scale < 1e-300 {
348 continue;
349 }
350 let rel_tol = tolerances.for_quantity(name);
351 if (a - b).abs() / scale > rel_tol {
352 return Err(Violation {
353 quantity: name.to_string(),
354 site: site.to_string(),
355 before: b,
356 after: a,
357 scale,
358 tolerance: rel_tol,
359 });
360 }
361 }
362 Ok(())
363}
364
365/// Something that can say what it is holding. Implemented by domains, and by
366/// anything else whose books are worth checking.
367pub trait Conserves {
368 /// What this is currently holding.
369 fn ledger(&self) -> Ledger;
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn a_ledger_that_did_not_move_passes() {
378 let before = Ledger::new()
379 .with(quantity::ENERGY, 3.7)
380 .with(quantity::MASS, 2.0);
381 // Losing the last bits of a double is arithmetic, not a leak.
382 let after = Ledger::new()
383 .with(quantity::ENERGY, 3.7 + 4e-16)
384 .with(quantity::MASS, 2.0);
385 assert!(audit("test", &before, &after, 1e-12).is_ok());
386 }
387
388 /// The failure is named, sited and quantified, because "conservation failed"
389 /// is not a debuggable message.
390 #[test]
391 fn a_leak_is_named_and_sited() {
392 let before = Ledger::new().with(quantity::ENERGY, 1.0);
393 let after = Ledger::new().with(quantity::ENERGY, 0.6);
394 let err = audit("thermal", &before, &after, 1e-9).expect_err("40% is not arithmetic");
395 assert_eq!(err.quantity, "energy");
396 assert_eq!(err.site, "thermal");
397 assert!((err.relative_error() - 0.4).abs() < 1e-12);
398 let text = err.to_string();
399 assert!(text.contains("destroyed"), "{text}");
400 assert!(text.contains("thermal"), "{text}");
401 }
402
403 #[test]
404 fn creating_something_reads_differently_from_losing_it() {
405 let before = Ledger::new().with(quantity::PHOTONS, 1e6);
406 let after = Ledger::new().with(quantity::PHOTONS, 1.5e6);
407 let err = audit("optics", &before, &after, 1e-6).unwrap_err();
408 assert!(err.to_string().contains("created"), "{err}");
409 }
410
411 /// A quantity that appears out of nowhere is a violation, not an exemption —
412 /// this is the case a naive "compare the keys they share" audit would miss.
413 #[test]
414 fn a_quantity_absent_before_is_still_audited() {
415 let before = Ledger::new().with(quantity::ENERGY, 1.0);
416 let after = Ledger::new()
417 .with(quantity::ENERGY, 1.0)
418 .with(quantity::MOMENTUM, 5.0);
419 let err = audit("contact", &before, &after, 1e-9).expect_err("momentum from nowhere");
420 assert_eq!(err.quantity, "momentum");
421 assert_eq!(err.before, 0.0);
422 }
423
424 /// Reports come out in one order, so a failing run names the same quantity
425 /// every time rather than whichever one the hash landed on first.
426 #[test]
427 fn the_audit_order_is_fixed() {
428 let before = Ledger::new()
429 .with(quantity::MOMENTUM, 1.0)
430 .with(quantity::CHARGE, 1.0)
431 .with(quantity::ENERGY, 1.0);
432 let after = Ledger::new()
433 .with(quantity::MOMENTUM, 2.0)
434 .with(quantity::CHARGE, 2.0)
435 .with(quantity::ENERGY, 2.0);
436 // Three laws broken at once; the alphabetically first is reported, every
437 // time, on every platform.
438 for _ in 0..8 {
439 let err = audit("s", &before, &after, 1e-9).unwrap_err();
440 assert_eq!(err.quantity, "charge");
441 }
442 }
443
444 #[test]
445 fn ledgers_merge_by_summing() {
446 let a = Ledger::new().with(quantity::ENERGY, 1.5);
447 let b = Ledger::new()
448 .with(quantity::ENERGY, 2.5)
449 .with(quantity::MASS, 1.0);
450 let total = a.merged(&b);
451 assert_eq!(total.get(quantity::ENERGY), Some(4.0));
452 assert_eq!(total.get(quantity::MASS), Some(1.0));
453 }
454
455 /// Zero against zero is not a hundred-percent error.
456 #[test]
457 fn nothing_compared_to_nothing_is_fine() {
458 let z = Ledger::new().with(quantity::ENERGY, 0.0);
459 assert!(audit("s", &z, &z, 0.0).is_ok());
460 }
461}