Skip to main content

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}