Skip to main content

gam_models/transformation_normal/
quantile_table.rs

1//! The fitted CTN conditional transform, tabulated — **with the derivative and
2//! the affine tails it actually has**, so that inverting it is a statement about
3//! the model rather than about the table.
4//!
5//! A conditional-transformation-normal model is `F(y | x) = Φ(h(y | x))` with
6//! `h(·|x)` strictly increasing, so every response-scale question a fitted CTN
7//! can be asked — the conditional mean `E[Y|x] = E_Z[h⁻¹(Z|x)]`, the predictive
8//! quantile ladder `h⁻¹(Φ⁻¹(p)|x)`, an inverse-transform draw `h⁻¹(Z|x)` — is
9//! one function, `h⁻¹`, evaluated at different latent arguments. This type is
10//! that function, and it is the ONLY place it is implemented.
11//!
12//! # One rule, no special cases
13//!
14//! The table carries `(y_k, h(y_k), h'(y_k))` per row — the chart computes the
15//! value and the derivative together at every node anyway — and the interpolant
16//! is the cubic Hermite those three determine. The two exterior branches are
17//! then not a separate convention: they are the same Hermite rule degenerating
18//! to its end slopes, because since gam#2600 the CTN transformation really is
19//! affine beyond the boundary knots at exactly `h'(y_lo)` and `h'(y_hi)`
20//! (`ctn_response_bases_at` continues the I-spline value basis linearly there).
21//!
22//! # Why the tails are part of the object
23//!
24//! `h` is tabulated on the fitted response support `[y_lo, y_hi]`, a bounded
25//! interval; the latent `Z` is not bounded. The tabulated rows therefore never
26//! cover the whole latent axis: `h(y_lo|x)` and `h(y_hi|x)` are finite numbers
27//! `L(x)`, `U(x)`, typically near `∓Φ⁻¹(1/(n+1))`, and every latent target
28//! outside `[L, U]` — which is `Φ(L) + 1 − Φ(U)` of the predictive mass, *by the
29//! model's own reckoning* — lands off the end of the table.
30//!
31//! Both inverters this type replaces answered such a target with the support
32//! endpoint. That is a truncation the fitted likelihood does not perform: it
33//! makes `y_lo` and `y_hi` atoms of the predictive law, pins every observation
34//! band at the training range no matter how extreme the level, and biases
35//! `E[Y|x]` inward. Measured on an intercept-only fit to `Y = exp(N(0,1))` at
36//! `n = 256`, `Φ(L) + 1 − Φ(U) = 2.5e-2`, and the 2.3 %, 0.13 % and 0.003 %
37//! predictive quantiles were the same number. gam#2600.
38//!
39//! Carrying the derivative alongside the values is what makes those tails
40//! impossible for a consumer to forget: there is no way to hold this object and
41//! not hold them.
42//!
43//! # Why cubic Hermite and not linear interpolation
44//!
45//! The interpolation error is not decoration either. A CTN transform is a
46//! degree-`(response_degree + 1)` piecewise polynomial whose curvature is
47//! largest exactly where the response is densest, so linear interpolation of a
48//! `G`-node table carries an `O(Δy²·h'')` latent error — measured at `2.1e-3` on
49//! the lognormal fixture above, against a reported ladder whose own step is
50//! `0.125`. Matching the derivative as well as the value raises that to
51//! `O(Δy⁴·h⁗)` at no extra evaluation cost, because `h'` was already computed at
52//! every node and thrown away.
53//!
54//! The interpolant is used only where it is provably monotone: a cell whose end
55//! slopes violate the Fritsch–Carlson bound relative to its own secant is
56//! under-resolved for a cubic, and falls back to the linear chord on that cell
57//! alone. Monotonicity is not a nicety here — it is what makes `invert` a
58//! function.
59
60use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
61
62/// Fritsch–Carlson sufficient bound on `h'(y_k)/secant` for the cubic Hermite
63/// interpolant of a cell to be monotone. The classical sufficient region is the
64/// disc `α² + β² ≤ 9`; the box `α, β ∈ [0, 3]` is the standard conservative
65/// inscription of it and is the one checked here, so a cell is only interpolated
66/// by a cubic when that cubic is certainly increasing.
67const HERMITE_MONOTONE_SLOPE_BOUND: f64 = 3.0;
68
69/// A per-row tabulation of the fitted CTN transform `h(·|x_i)` and its
70/// derivative on a shared response grid.
71///
72/// Invariants, all checked by [`CtnTransformTable::new`]:
73/// * `grid_y` has `g ≥ 2` finite, strictly increasing entries;
74/// * `h` is `n × g` and strictly increasing along every row;
75/// * `h_prime` is `n × g`, finite and strictly positive everywhere.
76///
77/// Together these make [`CtnTransformTable::invert`] a total, strictly
78/// increasing function of the latent argument on the whole real line: the
79/// tabulated part is a monotone bracket-and-solve, and the two tails are exact
80/// affine inverses at the end slopes.
81#[derive(Clone, Debug)]
82pub struct CtnTransformTable {
83    grid_y: Array1<f64>,
84    h: Array2<f64>,
85    h_prime: Array2<f64>,
86}
87
88impl CtnTransformTable {
89    /// Assemble a table, validating every invariant `invert` relies on.
90    ///
91    /// The validation is not defensive decoration: a non-monotone row makes the
92    /// bracketing search meaningless, and a zero or negative slope makes the
93    /// affine tail inverse point the wrong way (or to infinity). Both are
94    /// structurally impossible for a feasible CTN fit — `h' = ε + Σ_k M_k α_k`
95    /// with `α ≥ 0` on the monotonicity cone — so either one signals a corrupt
96    /// coefficient block, and the caller should hear about it here rather than
97    /// receive a silently wrong quantile.
98    pub fn new(grid_y: Array1<f64>, h: Array2<f64>, h_prime: Array2<f64>) -> Result<Self, String> {
99        let g = grid_y.len();
100        if g < 2 {
101            return Err(format!(
102                "CTN transform table needs at least two response grid nodes, got {g}"
103            ));
104        }
105        for k in 0..g {
106            if !grid_y[k].is_finite() {
107                return Err(format!(
108                    "CTN transform table response grid node {k} is not finite: {}",
109                    grid_y[k]
110                ));
111            }
112            if k > 0 && !(grid_y[k] > grid_y[k - 1]) {
113                return Err(format!(
114                    "CTN transform table response grid is not strictly increasing at node {k}: \
115                     {:.17e} -> {:.17e}",
116                    grid_y[k - 1],
117                    grid_y[k]
118                ));
119            }
120        }
121        let n = h.nrows();
122        if h.ncols() != g {
123            return Err(format!(
124                "CTN transform table has {} latent columns but {g} response grid nodes",
125                h.ncols()
126            ));
127        }
128        if h_prime.dim() != h.dim() {
129            return Err(format!(
130                "CTN transform table derivative is {:?} but the latent is {:?}",
131                h_prime.dim(),
132                h.dim()
133            ));
134        }
135        for i in 0..n {
136            for k in 0..g {
137                if !h[[i, k]].is_finite() {
138                    return Err(format!(
139                        "CTN transform table entry (row {i}, node {k}) is not finite: {}",
140                        h[[i, k]]
141                    ));
142                }
143                if k > 0 && !(h[[i, k]] > h[[i, k - 1]]) {
144                    return Err(format!(
145                        "CTN transform table row {i} is not strictly increasing between nodes \
146                         {} and {k}: {:.17e} -> {:.17e}",
147                        k - 1,
148                        h[[i, k - 1]],
149                        h[[i, k]]
150                    ));
151                }
152                let slope = h_prime[[i, k]];
153                if !(slope.is_finite() && slope > 0.0) {
154                    return Err(format!(
155                        "CTN transform table slope at (row {i}, node {k}) is {slope:.6e}; \
156                         h' = ε + Σ_k M_k·α_k is structurally positive on the monotonicity cone, \
157                         and the two END slopes are the slopes of the transform's affine tails"
158                    ));
159                }
160            }
161        }
162        Ok(Self { grid_y, h, h_prime })
163    }
164
165    /// Number of covariate rows the table carries.
166    pub fn nrows(&self) -> usize {
167        self.h.nrows()
168    }
169
170    /// The shared, strictly increasing response grid the transform is tabulated
171    /// on. Its first and last entries are the fitted support `[y_lo, y_hi]`, the
172    /// two points the affine tails are anchored at.
173    pub fn grid_y(&self) -> ArrayView1<'_, f64> {
174        self.grid_y.view()
175    }
176
177    /// `h[[i, k]] = h(grid_y[k] | x_i)` — the model's own latent, on the scale
178    /// the standard normal is compared against.
179    pub fn latent(&self) -> ArrayView2<'_, f64> {
180        self.h.view()
181    }
182
183    /// `(h'(y_lo | x_i), h'(y_hi | x_i))` — the slopes of row `i`'s two affine
184    /// tails, which are just the end columns of the derivative table.
185    pub fn tail_slopes(&self, row: usize) -> (f64, f64) {
186        let last = self.grid_y.len() - 1;
187        (self.h_prime[[row, 0]], self.h_prime[[row, last]])
188    }
189
190    /// `h(y | x_row)` — the tabulated transform itself, by the same rule
191    /// [`CtnTransformTable::invert`] inverts.
192    ///
193    /// The forward map is part of the contract, not a convenience: without it
194    /// "the inverse is the inverse of the interpolant" is not a statement anyone
195    /// can check, and the only available check would be against the exact chart,
196    /// which conflates a solver bug with an interpolation error.
197    pub fn evaluate(&self, row: usize, y: f64) -> f64 {
198        let g = self.grid_y.len();
199        let h = self.h.row(row);
200        let slope = self.h_prime.row(row);
201        if y <= self.grid_y[0] {
202            return h[0] + (y - self.grid_y[0]) * slope[0];
203        }
204        if y >= self.grid_y[g - 1] {
205            return h[g - 1] + (y - self.grid_y[g - 1]) * slope[g - 1];
206        }
207        let mut lo = 0usize;
208        let mut hi = g - 1;
209        while hi - lo > 1 {
210            let mid = (lo + hi) / 2;
211            if self.grid_y[mid] <= y {
212                lo = mid;
213            } else {
214                hi = mid;
215            }
216        }
217        let cell = self.cell(row, lo);
218        cell.value((y - self.grid_y[lo]) / cell.width)
219    }
220
221    /// `h⁻¹(target | x_row)` — the response value whose latent is `target`.
222    ///
223    /// Outside the tabulated range the transform is affine, so the inverse is
224    /// the exact affine inverse rather than the support endpoint. Inside, the
225    /// bracketing cell is inverted by safeguarded Newton on its Hermite
226    /// interpolant. The branches agree at the endpoints by construction
227    /// (`target == h[0]` returns `grid_y[0]` from either side), so the returned
228    /// quantile function is continuous and strictly increasing in `target` on
229    /// the whole real line.
230    pub fn invert(&self, row: usize, target: f64) -> f64 {
231        let g = self.grid_y.len();
232        let h = self.h.row(row);
233        let slope = self.h_prime.row(row);
234        if target <= h[0] {
235            return self.grid_y[0] + (target - h[0]) / slope[0];
236        }
237        if target >= h[g - 1] {
238            return self.grid_y[g - 1] + (target - h[g - 1]) / slope[g - 1];
239        }
240        let mut lo = 0usize;
241        let mut hi = g - 1;
242        while hi - lo > 1 {
243            let mid = (lo + hi) / 2;
244            if h[mid] <= target {
245                lo = mid;
246            } else {
247                hi = mid;
248            }
249        }
250        let cell = self.cell(row, lo);
251        self.grid_y[lo] + cell.width * cell.invert(target)
252    }
253
254    fn cell(&self, row: usize, index: usize) -> HermiteCell {
255        HermiteCell::new(
256            self.h[[row, index]],
257            self.h[[row, index + 1]],
258            self.h_prime[[row, index]],
259            self.h_prime[[row, index + 1]],
260            self.grid_y[index + 1] - self.grid_y[index],
261        )
262    }
263}
264
265/// One grid cell of a row, as the interpolant used on it.
266///
267/// `cubic` records whether the cell's own end slopes admit a monotone cubic; a
268/// cell that does not is under-resolved for one, and the linear chord — which is
269/// monotone whenever the tabulated values are — is used instead. Deciding this
270/// per cell rather than per table keeps a single ill-conditioned interval from
271/// coarsening the whole transform, and deciding it from the stored numbers keeps
272/// it deterministic and storage-free.
273struct HermiteCell {
274    h0: f64,
275    h1: f64,
276    m0: f64,
277    m1: f64,
278    width: f64,
279    cubic: bool,
280}
281
282impl HermiteCell {
283    fn new(h0: f64, h1: f64, m0: f64, m1: f64, width: f64) -> Self {
284        let secant = (h1 - h0) / width;
285        let cubic = secant > 0.0
286            && m0 <= HERMITE_MONOTONE_SLOPE_BOUND * secant
287            && m1 <= HERMITE_MONOTONE_SLOPE_BOUND * secant;
288        Self {
289            h0,
290            h1,
291            m0,
292            m1,
293            width,
294            cubic,
295        }
296    }
297
298    /// The interpolant at `t ∈ [0, 1]`, `y = y_k + t·width`.
299    fn value(&self, t: f64) -> f64 {
300        if !self.cubic {
301            return self.h0 + t * (self.h1 - self.h0);
302        }
303        let t2 = t * t;
304        let t3 = t2 * t;
305        (2.0 * t3 - 3.0 * t2 + 1.0) * self.h0
306            + (t3 - 2.0 * t2 + t) * self.width * self.m0
307            + (-2.0 * t3 + 3.0 * t2) * self.h1
308            + (t3 - t2) * self.width * self.m1
309    }
310
311    /// `d/dt` of [`HermiteCell::value`].
312    fn slope(&self, t: f64) -> f64 {
313        if !self.cubic {
314            return self.h1 - self.h0;
315        }
316        let t2 = t * t;
317        (6.0 * t2 - 6.0 * t) * self.h0
318            + (3.0 * t2 - 4.0 * t + 1.0) * self.width * self.m0
319            + (-6.0 * t2 + 6.0 * t) * self.h1
320            + (3.0 * t2 - 2.0 * t) * self.width * self.m1
321    }
322
323    /// The `t ∈ [0, 1]` with `value(t) == target`, by Newton safeguarded inside
324    /// a maintained bracket. The bracket exists because the caller only reaches
325    /// here with `h0 ≤ target ≤ h1`, and every step that leaves it is replaced
326    /// by a bisection, so the iteration cannot diverge on a cell whose cubic is
327    /// flat somewhere in the interior.
328    fn invert(&self, target: f64) -> f64 {
329        if !self.cubic {
330            return (target - self.h0) / (self.h1 - self.h0);
331        }
332        let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
333        let mut t = ((target - self.h0) / (self.h1 - self.h0)).clamp(0.0, 1.0);
334        for _ in 0..64 {
335            let value = self.value(t);
336            if value > target {
337                hi = t;
338            } else {
339                lo = t;
340            }
341            if hi - lo <= f64::EPSILON {
342                break;
343            }
344            let slope = self.slope(t);
345            let newton = t - (value - target) / slope;
346            let next = if slope > 0.0 && newton > lo && newton < hi {
347                newton
348            } else {
349                0.5 * (lo + hi)
350            };
351            if next == t {
352                break;
353            }
354            t = next;
355        }
356        t
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    /// `h(y) = slope·y` on `[-1, 1]` for two rows — exactly affine, so the
365    /// Hermite interpolant, the linear chord and the tails all coincide and any
366    /// deviation from `y = z/slope` is a bug rather than interpolation error.
367    fn affine_table() -> CtnTransformTable {
368        let grid_y = Array1::from_vec(vec![-1.0, -0.5, 0.0, 0.5, 1.0]);
369        let slopes = [2.0_f64, 4.0];
370        let h = Array2::from_shape_fn((2, 5), |(i, k)| slopes[i] * grid_y[k]);
371        let h_prime = Array2::from_shape_fn((2, 5), |(i, _)| slopes[i]);
372        CtnTransformTable::new(grid_y, h, h_prime).expect("valid affine table")
373    }
374
375    /// A curved transform sampled on a coarse grid: `h(y) = ln y` on `[0.1, 3]`,
376    /// the shape a lognormal response actually produces, where the difference
377    /// between matching the derivative and not matching it is the whole point.
378    fn log_table(nodes: usize) -> CtnTransformTable {
379        let (y_lo, y_hi) = (0.1_f64, 3.0_f64);
380        let grid_y = Array1::from_shape_fn(nodes, |k| {
381            y_lo + (y_hi - y_lo) * (k as f64) / ((nodes - 1) as f64)
382        });
383        let h = Array2::from_shape_fn((1, nodes), |(_, k)| grid_y[k].ln());
384        let h_prime = Array2::from_shape_fn((1, nodes), |(_, k)| 1.0 / grid_y[k]);
385        CtnTransformTable::new(grid_y, h, h_prime).expect("valid log table")
386    }
387
388    #[test]
389    fn inverse_is_exact_inside_the_table() {
390        let t = affine_table();
391        for &z in &[-2.0_f64, -1.0, 0.0, 1.0, 2.0] {
392            assert!((t.invert(0, z) - 0.5 * z).abs() < 1e-12, "z={z}");
393            assert!((t.invert(1, z) - 0.25 * z).abs() < 1e-12, "z={z}");
394        }
395    }
396
397    #[test]
398    fn inverse_extends_through_the_affine_tails_rather_than_clamping() {
399        // The whole of gam#2600's predictive residual in one assertion: a latent
400        // target past the tabulated range is not the support endpoint.
401        let t = affine_table();
402        for &z in &[-8.0_f64, -4.0, 4.0, 8.0] {
403            let y = t.invert(0, z);
404            assert!(
405                (y - 0.5 * z).abs() < 1e-12,
406                "tail inverse at z={z} is {y}, expected {}",
407                0.5 * z
408            );
409            assert!(
410                y.abs() > 1.0,
411                "tail inverse at z={z} clamped back inside the tabulated support"
412            );
413        }
414    }
415
416    #[test]
417    fn inverse_is_continuous_at_both_table_ends() {
418        let t = affine_table();
419        for &(z, expected) in &[(-2.0_f64, -1.0_f64), (2.0, 1.0)] {
420            let inside = t.invert(0, z * (1.0 - 1e-12));
421            let outside = t.invert(0, z * (1.0 + 1e-12));
422            assert!((inside - expected).abs() < 1e-9, "inside {inside}");
423            assert!((outside - expected).abs() < 1e-9, "outside {outside}");
424        }
425    }
426
427    /// `(hermite, chord)` worst-case inverse error of a `nodes`-point table for
428    /// `h = ln y`, the chord being recomputed from the SAME table so the only
429    /// thing the comparison isolates is the interpolation rule.
430    fn inverse_errors(nodes: usize) -> (f64, f64) {
431        let table = log_table(nodes);
432        let grid = table.grid_y().to_owned();
433        let h = table.latent();
434        let (mut hermite, mut chord) = (0.0_f64, 0.0_f64);
435        for step in 1..977 {
436            let y = 0.1 + (3.0 - 0.1) * (step as f64) / 977.0;
437            let z = y.ln();
438            hermite = hermite.max((table.invert(0, z) - y).abs());
439            let mut lo = 0usize;
440            let mut hi = grid.len() - 1;
441            while hi - lo > 1 {
442                let mid = (lo + hi) / 2;
443                if h[[0, mid]] <= z {
444                    lo = mid;
445                } else {
446                    hi = mid;
447                }
448            }
449            let t = (z - h[[0, lo]]) / (h[[0, hi]] - h[[0, lo]]);
450            chord = chord.max((grid[lo] + t * (grid[hi] - grid[lo]) - y).abs());
451        }
452        (hermite, chord)
453    }
454
455    #[test]
456    fn matching_the_node_derivative_raises_the_interpolation_order_from_two_to_four() {
457        // The structural claim behind spending an array on `h'`: it is not that
458        // the error is smaller at some grid size, it is that the SCHEME is
459        // fourth-order where the chord is second-order. That is what makes the
460        // difference grow as the table is refined, and it is what turns the
461        // `2.1e-3` chord error measured on the real fixture into `~2e-5`.
462        //
463        // `h = ln y` on `[0.1, 3]` is the shape a lognormal response produces —
464        // the curvature is concentrated exactly where the responses are.
465        let (coarse_hermite, coarse_chord) = inverse_errors(129);
466        let (fine_hermite, fine_chord) = inverse_errors(257);
467        let hermite_order = (coarse_hermite / fine_hermite).log2();
468        let chord_order = (coarse_chord / fine_chord).log2();
469        eprintln!(
470            "#2600 table: max|h^-1(ln y) - y| hermite {coarse_hermite:.3e} -> {fine_hermite:.3e} \
471             (order {hermite_order:.2})  chord {coarse_chord:.3e} -> {fine_chord:.3e} \
472             (order {chord_order:.2})"
473        );
474        assert!(
475            hermite_order > 3.0,
476            "the Hermite inverse is converging at order {hermite_order:.2}, not the ~4 a scheme \
477             that matches the node derivative must reach ({coarse_hermite:.6e} -> \
478             {fine_hermite:.6e})"
479        );
480        assert!(
481            chord_order < 2.5,
482            "the chord is converging at order {chord_order:.2}; if it were fourth-order too \
483             this comparison would not be measuring what it claims"
484        );
485        assert!(
486            fine_hermite * 20.0 < fine_chord,
487            "at the production table size the Hermite inverse is not decisively tighter than \
488             the chord: hermite={fine_hermite:.6e} chord={fine_chord:.6e}"
489        );
490    }
491
492    #[test]
493    fn the_inverse_inverts_the_interpolant_it_is_built_from() {
494        // Separates a solver bug from an interpolation error: whatever the
495        // Hermite interpolant is, `invert` must return its argument's preimage
496        // under exactly that interpolant, to round-off, on both tails and in
497        // every interior cell — including the strongly curved ones where the
498        // Newton iteration has to be safeguarded to stay in its bracket.
499        let table = log_table(17);
500        let mut worst = 0.0_f64;
501        for step in 0..=2000 {
502            let z = -9.0 + 18.0 * (step as f64) / 2000.0;
503            let y = table.invert(0, z);
504            worst = worst.max((table.evaluate(0, y) - z).abs());
505        }
506        eprintln!("#2600 table: max|H(H^-1(z)) - z| = {worst:.3e}");
507        assert!(
508            worst < 1.0e-12,
509            "invert is not the inverse of the interpolant it evaluates: {worst:.6e}"
510        );
511    }
512
513    #[test]
514    fn the_inverse_is_strictly_increasing_across_the_whole_latent_axis() {
515        let table = log_table(17);
516        let mut previous = f64::NEG_INFINITY;
517        for step in 0..=400 {
518            let z = -12.0 + 24.0 * (step as f64) / 400.0;
519            let y = table.invert(0, z);
520            assert!(
521                y > previous,
522                "h^-1 is not strictly increasing at z={z}: {y} <= {previous}"
523            );
524            previous = y;
525        }
526    }
527
528    #[test]
529    fn a_non_monotone_row_is_refused() {
530        let grid_y = Array1::from_vec(vec![0.0, 1.0, 2.0]);
531        let h = Array2::from_shape_vec((1, 3), vec![0.0, 0.5, 0.5]).expect("shape");
532        let h_prime = Array2::from_elem((1, 3), 1.0);
533        let error =
534            CtnTransformTable::new(grid_y, h, h_prime).expect_err("a flat row must be refused");
535        assert!(error.contains("strictly increasing"), "{error}");
536    }
537
538    #[test]
539    fn a_non_positive_slope_is_refused() {
540        let grid_y = Array1::from_vec(vec![0.0, 1.0]);
541        let h = Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).expect("shape");
542        let h_prime = Array2::from_shape_vec((1, 2), vec![0.0, 1.0]).expect("shape");
543        let error =
544            CtnTransformTable::new(grid_y, h, h_prime).expect_err("a zero slope must be refused");
545        assert!(error.contains("structurally positive"), "{error}");
546    }
547
548    #[test]
549    fn an_under_resolved_cell_falls_back_to_its_chord_and_stays_monotone() {
550        // End slopes far above the cell's own secant: the cubic through them is
551        // not monotone, so the cell must use its chord instead of producing an
552        // inverse that runs backwards.
553        let grid_y = Array1::from_vec(vec![0.0, 1.0, 2.0]);
554        let h = Array2::from_shape_vec((1, 3), vec![0.0, 1.0e-3, 1.0]).expect("shape");
555        let h_prime = Array2::from_shape_vec((1, 3), vec![5.0, 5.0, 1.0]).expect("shape");
556        let table = CtnTransformTable::new(grid_y, h, h_prime).expect("valid table");
557        let mut previous = f64::NEG_INFINITY;
558        for step in 0..=500 {
559            let z = -2.0 + 4.0 * (step as f64) / 500.0;
560            let y = table.invert(0, z);
561            assert!(
562                y > previous,
563                "the under-resolved cell produced a non-monotone inverse at z={z}: \
564                 {y} <= {previous}"
565            );
566            previous = y;
567        }
568    }
569}