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}