gam_problem/roundoff.rs
1//! One definition of "this residual is indistinguishable from zero".
2//!
3//! Two places in the engine have to decide whether a fitted mean reproduces its
4//! response *exactly*: the formula path's deterministic-Gaussian dispatch, which
5//! PREDICTS the state from the shape of the request, and the solver, which
6//! MEASURES it after the dispersion has been estimated. They must decide it the
7//! same way — a fit that is exact on one route and merely near-exact on the
8//! other reports a different scale, a different covariance, and a different
9//! criterion for the same data, which is how #2595 stayed invisible for a week.
10//!
11//! The shared quantity is Wilkinson's accumulated-roundoff growth factor. A
12//! floating-point sum of `k` operations carries a relative error bounded by
13//!
14//! ```text
15//! γ_k = k·ε / (1 − k·ε), ε = f64::EPSILON
16//! ```
17//!
18//! so a linear predictor `η_i = Σ_j x_ij β_j (+ offset)` formed from `p` terms
19//! cannot be trusted below `γ_{p+1} · (Σ_j |x_ij β_j| + |offset_i|)` — and a
20//! residual `y_i − η_i` smaller than that is not evidence of misfit, it is the
21//! arithmetic. This is a derived bound, not a tuned threshold: it moves with the
22//! model width and the data scale and has no free parameter.
23
24/// Wilkinson's growth factor `γ_k = k·ε/(1 − k·ε)` for a sum of `operations`
25/// floating-point operations.
26///
27/// `None` when `k·ε ≥ 1` — a model so wide that the accumulated bound exceeds
28/// the operands themselves, where no residual can be certified as roundoff and
29/// the caller must not treat any fit as exact.
30pub fn roundoff_growth_factor(operations: usize) -> Option<f64> {
31 let relative = (operations as f64) * f64::EPSILON;
32 (relative < 1.0).then(|| relative / (1.0 - relative))
33}
34
35/// Is a weighted residual sum of squares indistinguishable from zero?
36///
37/// `operand_scales[i]` is the sum of the magnitudes of the terms that formed row
38/// `i`'s residual — the caller supplies it, because only the caller knows which
39/// operands it summed. `terms` is the number of those operands, which fixes the
40/// growth factor.
41///
42/// Returns `true` when `Σ_i w_i r_i² ≤ Σ_i w_i (γ·scale_i)²`: every row's
43/// residual is, in aggregate, within the arithmetic's own resolution. Callers
44/// that can only supply a LOWER bound on the operand scale (`|y_i| + |η_i|`
45/// rather than `|y_i| + Σ_j |x_ij β_j|`) get a conservative answer — the
46/// predicate then fires less often, never more.
47pub fn weighted_residual_is_at_roundoff_floor(
48 weighted_rss: f64,
49 weights: impl IntoIterator<Item = f64>,
50 operand_scales: impl IntoIterator<Item = f64>,
51 terms: usize,
52) -> bool {
53 if !weighted_rss.is_finite() || weighted_rss < 0.0 {
54 return false;
55 }
56 let Some(gamma) = roundoff_growth_factor(terms) else {
57 return false;
58 };
59 let mut budget = 0.0_f64;
60 for (weight, scale) in weights.into_iter().zip(operand_scales) {
61 if !(weight.is_finite() && weight >= 0.0) || !scale.is_finite() {
62 return false;
63 }
64 let bound = gamma * scale.abs();
65 budget += weight * bound * bound;
66 }
67 budget.is_finite() && weighted_rss <= budget
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 #[test]
75 fn growth_factor_is_monotone_and_matches_the_closed_form() {
76 let one = roundoff_growth_factor(1).expect("k=1 is representable");
77 let ten = roundoff_growth_factor(10).expect("k=10 is representable");
78 assert!(one < ten);
79 assert!((one - f64::EPSILON / (1.0 - f64::EPSILON)).abs() <= f64::EPSILON * 1e-3);
80 }
81
82 #[test]
83 fn growth_factor_refuses_a_width_that_saturates_the_bound() {
84 // k·ε ≥ 1 means the accumulated bound is no smaller than the operands.
85 assert_eq!(roundoff_growth_factor(usize::MAX), None);
86 }
87
88 #[test]
89 fn an_exactly_zero_residual_is_at_the_floor() {
90 assert!(weighted_residual_is_at_roundoff_floor(
91 0.0,
92 vec![1.0; 4],
93 vec![1.0; 4],
94 3
95 ));
96 }
97
98 #[test]
99 fn a_one_ulp_residual_on_unit_data_is_at_the_floor() {
100 // Four rows, each off by one ulp of a unit-scale operand pair.
101 let residual = f64::EPSILON;
102 let rss = 4.0 * residual * residual;
103 assert!(weighted_residual_is_at_roundoff_floor(
104 rss,
105 vec![1.0; 4],
106 vec![2.0; 4],
107 3
108 ));
109 }
110
111 #[test]
112 fn ordinary_noise_is_not_at_the_floor() {
113 let rss = 4.0 * 1.0e-3 * 1.0e-3;
114 assert!(!weighted_residual_is_at_roundoff_floor(
115 rss,
116 vec![1.0; 4],
117 vec![2.0; 4],
118 3
119 ));
120 }
121
122 #[test]
123 fn the_bound_scales_with_the_data() {
124 // The same RELATIVE misfit is at the floor on large data and not on
125 // small: the predicate is scale-covariant, not an absolute epsilon.
126 let residual = 1.0e6 * f64::EPSILON;
127 let rss = 4.0 * residual * residual;
128 assert!(weighted_residual_is_at_roundoff_floor(
129 rss,
130 vec![1.0; 4],
131 vec![2.0e6; 4],
132 3
133 ));
134 assert!(!weighted_residual_is_at_roundoff_floor(
135 rss,
136 vec![1.0; 4],
137 vec![2.0; 4],
138 3
139 ));
140 }
141
142 #[test]
143 fn zero_weight_rows_contribute_no_budget() {
144 // A zero-weight row is equivalent to an absent row on both sides of the
145 // comparison, so it can neither create nor consume slack.
146 assert!(!weighted_residual_is_at_roundoff_floor(
147 1.0,
148 vec![0.0; 4],
149 vec![1.0e300; 4],
150 3
151 ));
152 }
153}