Skip to main content

gam_models/transformation_normal/
chart.rs

1//! The CTN coefficient chart — the single definition of what `β` means.
2//!
3//! Everything that turns fitted CTN coefficients into a transformed response
4//! goes through this module. That is not a stylistic preference: the family, the
5//! predictor, the observed-score path, the generated-regressor Jacobian and the
6//! ALO row replay all read the *same* `blocks[0].beta`, so if any one of them
7//! reads it through a different chart the model silently means two different
8//! things at fit time and at predict time. gam#2680 is exactly that failure —
9//! `#2306` moved the likelihood onto the direct-α chart and left three consumers
10//! evaluating `Σ_k I_k(y)·γ_k(x)²`, which reproduces a fitted score of
11//! `c·z + (1−c)·L` instead of `z` and is *identically correct only at `c = 1`*
12//! (hence: passes small fixtures, fails at production `n`).
13//!
14//! # The chart
15//!
16//! `β` is `vec(A)` for a `p_resp × p_cov` coefficient matrix `A`. The
17//! covariate-side coordinates are
18//!
19//! ```text
20//! α_k(x) = ψ(x)ᵀ A[k, :],      k = 0 .. p_resp−1
21//! ```
22//!
23//! and the transform is **affine in `α`**, with the shape coordinates kept
24//! non-negative by the factored Khatri-Rao monotonicity cone
25//! (`TransformationNormalFamily::block_linear_constraints`) rather than by a
26//! squared latent reparameterization:
27//!
28//! ```text
29//! h(y, x)  = Σ_k value_k(y)      · α_k(x) + offset(x) + ε·(y − median)
30//! h'(y, x) = Σ_k derivative_k(y) · α_k(x) + ε
31//! L(x)     = Σ_k lower_k         · α_k(x) + offset(x) + ε·(y_lo − median)
32//! U(x)     = Σ_k upper_k         · α_k(x) + offset(x) + ε·(y_hi − median)
33//! ```
34//!
35//! with `value = [1, I_1(y), …]`, `derivative = [0, M_1(y), …]`,
36//! `lower = [1, 0, …, 0]` and `upper = [1, 1ᵀT_{·1}, …]` — the same I-splines
37//! evaluated at the two boundary knots, where every anchored `I_k` is exactly
38//! `0` and exactly `1` respectively.
39//!
40//! Because the chart is affine, the derivative of every one of those four
41//! quantities with respect to `A[k, j]` is just `basis_k · ψ_j(x)` — no chart
42//! factor. [`ctn_row_geometry`] and [`CtnRowBases`] are the only place that
43//! statement is written down.
44
45use super::{
46    ISplineBoundary, TRANSFORMATION_MONOTONICITY_EPS, initializewiggle_knots_from_seed,
47    ispline_modelling_interval, ispline_value_and_first_derivative,
48};
49use crate::inference::model::TransformationNormalParameterization;
50use ndarray::{Array1, Array2, ArrayView1};
51
52/// Number of leading response-basis columns that carry the unconstrained
53/// location field `b(x)` rather than a monotone shape coordinate. The location
54/// column is the constant `1` in the value basis and `0` in the derivative
55/// basis, and it is the one coordinate the monotonicity cone does not
56/// constrain.
57pub const CTN_LOCATION_COLUMNS: usize = 1;
58
59/// Fraction of the response span by which the certified support is widened past
60/// the observed extremes, so every observation the knots were built from sits
61/// STRICTLY inside `[y_lo, y_hi]` rather than on its boundary. A response
62/// exactly at an endpoint would make its PIT exactly `0` or `1` and clip, which
63/// is a real score for a genuinely extreme observation but a fabricated one for
64/// the sample maximum of any finite sample.
65pub const CTN_RESPONSE_SUPPORT_GUARD_FRACTION: f64 = 1.0e-3;
66
67/// Response-direction basis rows for one observation, in the chart's own order.
68///
69/// All four slices are `p_resp` long and are indexed by the same `k` as `alpha`.
70#[derive(Clone, Copy, Debug)]
71pub struct CtnRowBases<'a> {
72    /// `[1, I_1(y_i), …]` — the value basis at this row's response.
73    pub value: ArrayView1<'a, f64>,
74    /// `[0, M_1(y_i), …]` — the derivative basis at this row's response.
75    pub derivative: ArrayView1<'a, f64>,
76    /// `[1, 0, …, 0]` — the value basis at the lower support knot.
77    pub lower: ArrayView1<'a, f64>,
78    /// `[1, 1ᵀT_{·1}, …]` — the value basis at the upper support knot.
79    pub upper: ArrayView1<'a, f64>,
80}
81
82/// The additive scalars that do not depend on `α`: the composed linear-predictor
83/// offset (which enters `h`, `L` and `U` identically) and the three
84/// monotonicity-floor terms `ε·(y − median)`.
85#[derive(Clone, Copy, Debug)]
86pub struct CtnRowFloors {
87    /// The composed additive offset for this row.
88    pub additive_offset: f64,
89    /// `ε·(y_i − median)`.
90    pub value_floor: f64,
91    /// `ε·(y_lo − median)`.
92    pub lower_floor: f64,
93    /// `ε·(y_hi − median)`.
94    pub upper_floor: f64,
95}
96
97/// The transformed response and its support at one row.
98#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct CtnRowGeometry {
100    /// `h(y_i, x_i)`.
101    pub h: f64,
102    /// `h'(y_i, x_i)`, always `≥ ε` by construction on a feasible `α`.
103    pub h_prime: f64,
104    /// `L(x_i) = h(y_lo, x_i)`.
105    pub lower: f64,
106    /// `U(x_i) = h(y_hi, x_i)`.
107    pub upper: f64,
108}
109
110/// One affine chart component: `floor + Σ_k basis_k · α_k`.
111///
112/// The accumulation starts from `basis[0]·α[0] + floor` and then adds the shape
113/// terms in index order — the order the family's row build has always used, so
114/// routing that build through here is bit-identical rather than merely
115/// mathematically equal.
116///
117/// `basis` is read at every index of `alpha`; a caller that passes mismatched
118/// widths gets a bounds panic, which is the correct outcome for a corrupted
119/// coefficient layout (the surrounding paths all validate `p_resp` first).
120///
121/// The arguments are strided views rather than slices deliberately. `α` is a row
122/// of `Ψ · Aᵀ`, whose memory layout is the linear-algebra backend's business —
123/// requiring contiguity here would make a correct chart evaluation depend on
124/// whether a matrix product happened to come back row-major, which is a latent
125/// panic waiting for a shape that flips it.
126#[inline]
127pub fn ctn_chart_component(
128    alpha: ArrayView1<'_, f64>,
129    basis: ArrayView1<'_, f64>,
130    floor: f64,
131) -> f64 {
132    let mut acc = basis[0] * alpha[0] + floor;
133    for k in CTN_LOCATION_COLUMNS..alpha.len() {
134        acc += basis[k] * alpha[k];
135    }
136    acc
137}
138
139/// Evaluate the CTN transform geometry at one row from its covariate-side
140/// coordinates `α_k(x_i)`.
141///
142/// This is *the* definition of the chart. Every consumer — the likelihood, the
143/// PIT score, the `E[Y|x]` inversion grid, the generated-regressor Jacobian, the
144/// ALO row replay — calls it, so none of them can drift onto a different
145/// parameterization of the same `β`.
146///
147/// `chart` is not decoration. A persisted CTN model carries
148/// [`TransformationNormalParameterization`] precisely so *"a reader can reject
149/// coefficients written under any other chart as a typed mismatch instead of
150/// silently reinterpreting them"* — and before gam#2680 every replay path
151/// validated that marker and then reinterpreted the coefficients anyway.
152/// Requiring it here makes the marker load-bearing: a replay path must name the
153/// chart it believes it is evaluating, and adding a second variant is a compile
154/// error in exactly one function instead of a silent divergence in five.
155#[inline]
156pub fn ctn_row_geometry(
157    chart: TransformationNormalParameterization,
158    alpha: ArrayView1<'_, f64>,
159    bases: CtnRowBases<'_>,
160    floors: CtnRowFloors,
161) -> CtnRowGeometry {
162    match chart {
163        TransformationNormalParameterization::DirectAlpha => CtnRowGeometry {
164            h: ctn_chart_component(
165                alpha,
166                bases.value,
167                floors.additive_offset + floors.value_floor,
168            ),
169            h_prime: ctn_chart_component(alpha, bases.derivative, TRANSFORMATION_MONOTONICITY_EPS),
170            lower: ctn_chart_component(
171                alpha,
172                bases.lower,
173                floors.additive_offset + floors.lower_floor,
174            ),
175            upper: ctn_chart_component(
176                alpha,
177                bases.upper,
178                floors.additive_offset + floors.upper_floor,
179            ),
180        },
181    }
182}
183
184/// The derivative of every component of [`ctn_row_geometry`] with respect to the
185/// coordinate `α_k` — which, the chart being affine, is just the basis entry.
186///
187/// Stated as a function so a consumer that differentiates the transform cannot
188/// invent a chart factor the evaluator does not have. The pre-gam#2680
189/// generated-regressor Jacobian carried `2·γ_k` here, the derivative of the
190/// squared chart, against a value path that had already moved.
191#[inline]
192pub fn ctn_component_sensitivity(
193    chart: TransformationNormalParameterization,
194    basis: ArrayView1<'_, f64>,
195    k: usize,
196) -> f64 {
197    match chart {
198        TransformationNormalParameterization::DirectAlpha => basis[k],
199    }
200}
201
202/// Number of knots the I-spline response basis carries for a given degree and
203/// internal-knot count.
204///
205/// The builder integrates a degree-`(response_degree + 1)` B-spline basis, so
206/// the seed produces `k_prime = K − 2` interior knots inside a clamped vector
207/// with `response_degree + 2` boundary repeats at each end.
208pub fn ctn_response_knot_count(
209    response_degree: usize,
210    response_num_internal_knots: usize,
211) -> Result<usize, String> {
212    let k_prime = response_num_internal_knots.checked_sub(2).ok_or_else(|| {
213        format!(
214            "response_num_internal_knots = {response_num_internal_knots}; I-spline contract \
215             requires K' = K − 2 ≥ 0, so need K ≥ 2"
216        )
217    })?;
218    Ok(k_prime + 2 * (response_degree + 2))
219}
220
221/// The clamped I-spline knot vector for a response column, and with it the
222/// **certified response support** `[knots.first, knots.last]` that every PIT
223/// normalizes against.
224///
225/// Interior knots come from the wiggle seed; the boundary repeats are pinned to
226/// `[min − guard, max + guard]` with a guard of `0.1 %` of the response span, so
227/// every observation used to build them sits strictly inside the support.
228///
229/// This is a function rather than an inline block in
230/// `super::build_response_basis` because the support it defines is a *shared*
231/// object whenever more than one CTN fit has to produce comparable scores. The
232/// cross-fit Stage-1 calibration is exactly that case: it refits the CTN on each
233/// fold complement and evaluates the score on the held-out rows, so a
234/// fold-local support both (a) fails outright on whichever fold holds out a
235/// response extreme — the held-out row is then outside its own fold's certified
236/// domain and the PIT refuses it — and (b) when it does not fail, assembles the
237/// out-of-fold score from `K` PITs taken against `K` *different* truncations,
238/// which is not one latent scale. Resolving it once on the full response and
239/// pinning it (`TransformationNormalConfig::response_knots_pinned`) removes both.
240pub fn ctn_response_knots(
241    response: ArrayView1<'_, f64>,
242    response_degree: usize,
243    response_num_internal_knots: usize,
244) -> Result<Array1<f64>, String> {
245    let k_prime = response_num_internal_knots.checked_sub(2).ok_or_else(|| {
246        format!(
247            "response_num_internal_knots = {response_num_internal_knots}; I-spline contract \
248             requires K' = K − 2 ≥ 0, so need K ≥ 2"
249        )
250    })?;
251    // The I-spline builder integrates a degree-`(response_degree + 1)` B-spline
252    // basis into a degree-`response_degree` value basis, so the seed-time degree
253    // is `response_degree + 1`.
254    let mut knots = initializewiggle_knots_from_seed(response, response_degree + 1, k_prime)?;
255    let response_min = response.iter().copied().fold(f64::INFINITY, f64::min);
256    let response_max = response.iter().copied().fold(f64::NEG_INFINITY, f64::max);
257    let response_span = (response_max - response_min).abs().max(1.0);
258    let support_guard = response_span * CTN_RESPONSE_SUPPORT_GUARD_FRACTION;
259    let boundary_repeats = response_degree + 2;
260    if knots.len() >= 2 * boundary_repeats {
261        for idx in 0..boundary_repeats {
262            knots[idx] = response_min - support_guard;
263            let right_idx = knots.len() - 1 - idx;
264            knots[right_idx] = response_max + support_guard;
265        }
266    }
267    Ok(knots)
268}
269
270/// The knot vector a CTN fit will actually use: the pinned one when the config
271/// carries it, otherwise one resolved from this fit's own response.
272///
273/// This is the single decision point for "whose response defines the certified
274/// support", which is why it is a named function rather than a branch inside
275/// `super::build_response_basis` — the cross-fit needs to make that decision
276/// and needs to be able to check it (gam#2680).
277pub fn ctn_resolved_response_knots(
278    response: ArrayView1<'_, f64>,
279    response_degree: usize,
280    response_num_internal_knots: usize,
281    pinned: Option<&Array1<f64>>,
282) -> Result<Array1<f64>, String> {
283    let expected = ctn_response_knot_count(response_degree, response_num_internal_knots)?;
284    match pinned {
285        Some(knots) => {
286            if knots.len() != expected {
287                return Err(format!(
288                    "pinned response knot vector has {} entries but degree {response_degree} with \
289                     {response_num_internal_knots} internal knots requires {expected}",
290                    knots.len()
291                ));
292            }
293            Ok(knots.clone())
294        }
295        None => ctn_response_knots(response, response_degree, response_num_internal_knots),
296    }
297}
298
299/// The endpoint value bases `(lower, upper)` for a response-shape coefficient
300/// transform `T`.
301///
302/// An anchored I-spline satisfies `I_k(y_lo) = 0` and `I_k(y_hi) = 1` exactly,
303/// so the lower endpoint reads only the location column and the upper endpoint
304/// reads the column sums of `T`. Stating the endpoints structurally rather than
305/// re-evaluating the basis at the boundary knots is what makes `U − L` exactly
306/// the represented support width instead of that width plus two evaluations of
307/// round-off.
308pub fn ctn_endpoint_bases(transform: &Array2<f64>) -> (Array1<f64>, Array1<f64>) {
309    let p_shape = transform.ncols();
310    let mut lower = Array1::<f64>::zeros(p_shape + CTN_LOCATION_COLUMNS);
311    let mut upper = Array1::<f64>::zeros(p_shape + CTN_LOCATION_COLUMNS);
312    lower[0] = 1.0;
313    upper[0] = 1.0;
314    for col in 0..p_shape {
315        upper[col + CTN_LOCATION_COLUMNS] = transform.column(col).sum();
316    }
317    (lower, upper)
318}
319
320/// The three floor scalars for a whole column of responses.
321///
322/// Returns `(per-row ε·(y_i − median), ε·(y_lo − median), ε·(y_hi − median))`
323/// with the support endpoints taken from the fitted knot vector.
324pub fn ctn_floor_offsets(
325    response: ArrayView1<'_, f64>,
326    knots: ArrayView1<'_, f64>,
327    response_median: f64,
328) -> Result<(Array1<f64>, f64, f64), String> {
329    let (Some(&lower_y), Some(&upper_y)) = (knots.first(), knots.last()) else {
330        return Err("CTN floor offsets require a non-empty response knot vector".to_string());
331    };
332    let row_offsets = response.mapv(|y| TRANSFORMATION_MONOTONICITY_EPS * (y - response_median));
333    Ok((
334        row_offsets,
335        TRANSFORMATION_MONOTONICITY_EPS * (lower_y - response_median),
336        TRANSFORMATION_MONOTONICITY_EPS * (upper_y - response_median),
337    ))
338}
339
340/// Build the response-direction value and derivative bases `[1, I(y)·T]` and
341/// `[0, M(y)·T]` at arbitrary response values, on the frozen knots/degree.
342///
343/// `transform = None` means the identity chart (`T = I`), which is what
344/// `super::build_response_basis` constructs and therefore what the fit uses;
345/// a persisted model passes its saved `T` so a prediction reproduces the fitted
346/// basis exactly. The two callers differing on how the location column is
347/// prepended is precisely the class of bug this function exists to remove.
348///
349/// # Why the CTN transformation cannot saturate outside its knots
350///
351/// A conditional transformation-normal model *is* the statement
352/// `F(y | x) = Φ(h(y | x))`, and gam#2600 removed the endpoint renormalizer that
353/// used to truncate it — so the fitted density `φ(h)·h'` is a density on the
354/// whole real line and `h` is its quantile map. Under
355/// [`ISplineBoundary::Saturate`] — which is what the shared evaluator does by
356/// default, for the reasons its own module documents — `I_k` is constant and
357/// `M_k` is zero outside `[t_q, t_{n_B}]`, so the whole exterior of the fitted
358/// transform is
359///
360/// ```text
361/// h(y) = h(y_b) + ε·(y − y_b),   ε = TRANSFORMATION_MONOTONICITY_EPS = 1e-8,
362/// ```
363///
364/// i.e. the model's entire tail behaviour is a readout of a numerical floor.
365/// Measured on an intercept-only fit to `Y = exp(N(0,1))` at `n = 256`
366/// (gam#2600): `Φ(h(y_lo)) = 2.4e-2` of the model's own predictive mass sits
367/// below the tabulated support, the transform needs `Δy ≈ 1.4e8` to carry `Φ(h)`
368/// from `Φ(U)` to `0.9999`, and two responses a factor `1.8` apart on the far
369/// side of the boundary receive PIT scores identical to seven digits.
370///
371/// [`ISplineBoundary::LinearTails`] is the classical answer — Royston-Parmar's
372/// linear tails, `mlt`'s `extrapolate` — and gives a `C¹`, strictly increasing
373/// `h` on all of `ℝ` with `h' ≥ ε` preserved (`M_k(y_b) ≥ 0`, `α ≥ 0` on the
374/// monotonicity cone), so `Φ(h)` is a proper CDF whose tails are the fitted
375/// transform's. Evaluation AT or INSIDE the knots is bit-identical, and
376/// `ctn_response_knots` guards the support by `0.1 %` of the response span so
377/// every training row is strictly inside — no fitted quantity moves.
378///
379/// The exterior I-spline entries do leave `[0, 1]`, which is why the convention
380/// is a parameter of the shared evaluator rather than its default: for the CTN
381/// the entries are coefficients of a transformation that must keep increasing,
382/// not weights that must stay in a simplex, and the survival link warp that
383/// shares the evaluator (gam#2695) genuinely wants the saturating convention.
384///
385/// This does mean the fitted CTN puts mass on the whole line, so a strictly
386/// positive response can be extrapolated below zero. That is a property of a
387/// Gaussian transformation model on the raw response scale — the same property
388/// the likelihood is already maximised under — and it is honest where a clamp
389/// was not; a model that must respect a bound belongs on the transformed scale
390/// (fit `log y`), exactly as `mlt`'s `log_first` does.
391///
392/// The continuation is applied to the RAW I-spline frame, before `T`; the chart
393/// is linear in the basis, so extending-then-transforming and
394/// transforming-then-extending are the same matrix and the raw frame is where
395/// the boundary derivative is defined.
396pub fn ctn_response_bases_at(
397    response: ArrayView1<'_, f64>,
398    knots: ArrayView1<'_, f64>,
399    degree: usize,
400    transform: Option<&Array2<f64>>,
401) -> Result<(Array2<f64>, Array2<f64>), String> {
402    // The interval is read off the same index arithmetic the evaluator uses
403    // rather than as `knots.first()` / `knots.last()`: on the clamped vectors
404    // `ctn_response_knots` builds the two agree, but it is the EVALUATOR's
405    // interval that decides where the basis stops being a spline and starts
406    // being an extension. A degenerate one is refused here rather than silently
407    // producing a basis with no exterior.
408    ispline_modelling_interval(knots, degree)
409        .map_err(|error| format!("CTN response I-spline knot vector is unusable: {error}"))?
410        .ok_or_else(|| {
411            format!(
412                "CTN response I-spline modelling interval is degenerate for degree {degree} on \
413                 {} knot(s)",
414                knots.len()
415            )
416        })?;
417    let (raw_value, raw_derivative) = ispline_value_and_first_derivative(
418        response,
419        knots,
420        degree,
421        ISplineBoundary::LinearTails,
422    )
423    .map_err(|error| format!("CTN response I-spline value/derivative bases failed: {error}"))?;
424
425    let (shape_value, shape_derivative) = match transform {
426        Some(t) => {
427            if raw_value.ncols() != t.nrows() {
428                return Err(format!(
429                    "CTN response transform has {} rows but the I-spline basis has {} columns",
430                    t.nrows(),
431                    raw_value.ncols()
432                ));
433            }
434            (raw_value.dot(t), raw_derivative.dot(t))
435        }
436        None => (raw_value, raw_derivative),
437    };
438    if shape_derivative.ncols() != shape_value.ncols() {
439        return Err(format!(
440            "CTN response derivative basis has {} columns but the value basis has {}",
441            shape_derivative.ncols(),
442            shape_value.ncols()
443        ));
444    }
445
446    let n = shape_value.nrows();
447    let p_resp = shape_value.ncols() + CTN_LOCATION_COLUMNS;
448    let mut value = Array2::<f64>::zeros((n, p_resp));
449    let mut derivative = Array2::<f64>::zeros((n, p_resp));
450    value.column_mut(0).fill(1.0);
451    value
452        .slice_mut(ndarray::s![.., CTN_LOCATION_COLUMNS..])
453        .assign(&shape_value);
454    derivative
455        .slice_mut(ndarray::s![.., CTN_LOCATION_COLUMNS..])
456        .assign(&shape_derivative);
457    Ok((value, derivative))
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    fn bases<'a>(
465        value: &'a [f64],
466        derivative: &'a [f64],
467        lower: &'a [f64],
468        upper: &'a [f64],
469    ) -> CtnRowBases<'a> {
470        CtnRowBases {
471            value: ArrayView1::from(value),
472            derivative: ArrayView1::from(derivative),
473            lower: ArrayView1::from(lower),
474            upper: ArrayView1::from(upper),
475        }
476    }
477
478    #[test]
479    fn chart_component_is_affine_in_alpha() {
480        // The defining property: doubling alpha doubles the response of every
481        // component about its floor. A squared chart fails this by 4x on the
482        // shape block, which is exactly gam#2680.
483        let basis = [1.0, 0.4, 0.9];
484        let alpha = [0.5, 1.5, 2.5];
485        let doubled: Vec<f64> = alpha.iter().map(|a| 2.0 * a).collect();
486        let floor = -0.25;
487        let base = ctn_chart_component(
488            ArrayView1::from(&alpha[..]),
489            ArrayView1::from(&basis[..]),
490            floor,
491        );
492        let twice = ctn_chart_component(
493            ArrayView1::from(&doubled[..]),
494            ArrayView1::from(&basis[..]),
495            floor,
496        );
497        assert!(
498            ((twice - floor) - 2.0 * (base - floor)).abs() < 1e-14,
499            "chart is not affine: base={base} twice={twice}"
500        );
501    }
502
503    #[test]
504    fn row_geometry_matches_the_hand_written_chart() {
505        let value = [1.0, 0.25, 0.75];
506        let derivative = [0.0, 0.6, 1.1];
507        let lower = [1.0, 0.0, 0.0];
508        let upper = [1.0, 1.0, 1.0];
509        let alpha = [-1.5, 0.8, 1.2];
510        let floors = CtnRowFloors {
511            additive_offset: 0.1,
512            value_floor: 1.0e-9,
513            lower_floor: -2.0e-9,
514            upper_floor: 3.0e-9,
515        };
516        let g = ctn_row_geometry(
517            TransformationNormalParameterization::DirectAlpha,
518            ArrayView1::from(&alpha[..]),
519            bases(&value, &derivative, &lower, &upper),
520            floors,
521        );
522        let expect_h = 0.1 + 1.0e-9 + 1.0 * -1.5 + 0.25 * 0.8 + 0.75 * 1.2;
523        let expect_hp = TRANSFORMATION_MONOTONICITY_EPS + 0.6 * 0.8 + 1.1 * 1.2;
524        let expect_lo = 0.1 - 2.0e-9 + -1.5;
525        let expect_hi = 0.1 + 3.0e-9 + -1.5 + 0.8 + 1.2;
526        assert!((g.h - expect_h).abs() < 1e-12, "h {} vs {expect_h}", g.h);
527        assert!(
528            (g.h_prime - expect_hp).abs() < 1e-12,
529            "h' {} vs {expect_hp}",
530            g.h_prime
531        );
532        assert!((g.lower - expect_lo).abs() < 1e-12);
533        assert!((g.upper - expect_hi).abs() < 1e-12);
534    }
535
536    #[test]
537    fn endpoint_bases_read_the_location_column_and_the_transform_column_sums() {
538        let transform = ndarray::array![[1.0, 0.0], [0.5, 2.0], [0.25, -1.0]];
539        let (lower, upper) = ctn_endpoint_bases(&transform);
540        assert_eq!(lower.to_vec(), vec![1.0, 0.0, 0.0]);
541        assert_eq!(upper.to_vec(), vec![1.0, 1.75, 1.0]);
542    }
543
544    #[test]
545    fn support_width_is_the_shape_coordinates_own_scale() {
546        // U − L on the identity chart is exactly Σ_k α_k: the represented span
547        // of the transformation. Under a squared chart it would be Σ_k α_k²,
548        // which is the quantity gam#2680's over-dispersion is a readout of.
549        let transform = Array2::<f64>::eye(3);
550        let (lower, upper) = ctn_endpoint_bases(&transform);
551        let alpha = [-2.0, 0.7, 1.3, 0.5];
552        let floors = CtnRowFloors {
553            additive_offset: 0.0,
554            value_floor: 0.0,
555            lower_floor: 0.0,
556            upper_floor: 0.0,
557        };
558        let value = [1.0, 0.0, 0.0, 0.0];
559        let derivative = [0.0, 1.0, 1.0, 1.0];
560        let g = ctn_row_geometry(
561            TransformationNormalParameterization::DirectAlpha,
562            ArrayView1::from(&alpha[..]),
563            bases(
564                &value,
565                &derivative,
566                lower.as_slice().expect("contiguous"),
567                upper.as_slice().expect("contiguous"),
568            ),
569            floors,
570        );
571        assert!((g.upper - g.lower - (0.7 + 1.3 + 0.5)).abs() < 1e-12);
572    }
573
574    #[test]
575    fn response_bases_carry_the_location_column_and_a_zero_derivative_column() {
576        let knots = Array1::from_vec(vec![
577            -1.2, -1.2, -1.2, -1.2, -1.2, 0.0, 1.2, 1.2, 1.2, 1.2, 1.2,
578        ]);
579        let y = Array1::from_vec(vec![-1.2, -0.3, 0.4, 1.2]);
580        let (value, derivative) =
581            ctn_response_bases_at(y.view(), knots.view(), 3, None).expect("bases");
582        assert_eq!(value.nrows(), 4);
583        assert_eq!(value.ncols(), derivative.ncols());
584        for i in 0..4 {
585            assert_eq!(value[[i, 0]], 1.0, "location column must be 1");
586            assert_eq!(derivative[[i, 0]], 0.0, "location column carries no slope");
587        }
588        // Anchored I-splines: every shape column is 0 at the left boundary knot
589        // and 1 at the right one — the structural fact `ctn_endpoint_bases`
590        // encodes.
591        for k in CTN_LOCATION_COLUMNS..value.ncols() {
592            assert!(
593                value[[0, k]].abs() < 1e-12,
594                "I_{k}(y_lo) = {} is not 0",
595                value[[0, k]]
596            );
597            assert!(
598                (value[[3, k]] - 1.0).abs() < 1e-12,
599                "I_{k}(y_hi) = {} is not 1",
600                value[[3, k]]
601            );
602        }
603    }
604
605    /// A clamped I-spline knot vector for `degree = 3`: `degree + 2 = 5`
606    /// boundary repeats at each end, one interior knot, support `[-1.2, 1.2]`.
607    fn tail_knots() -> Array1<f64> {
608        Array1::from_vec(vec![
609            -1.2, -1.2, -1.2, -1.2, -1.2, 0.0, 1.2, 1.2, 1.2, 1.2, 1.2,
610        ])
611    }
612
613    #[test]
614    fn response_bases_continue_affinely_past_the_knots_2600() {
615        // The defect gam#2600 left behind: `I_k` saturated outside the knot
616        // range and `M_k` was zero there, so the CTN transform's whole exterior
617        // was `h(y_b) + ε·(y − y_b)` with `ε = 1e-8`. The basis must instead
618        // continue at its own boundary derivative.
619        let knots = tail_knots();
620        let (y_lo, y_hi) = (-1.2_f64, 1.2_f64);
621        let steps = [1.0e-6_f64, 0.25, 3.0, 250.0];
622        let mut points = vec![y_lo, y_hi];
623        for step in steps {
624            points.push(y_lo - step);
625            points.push(y_hi + step);
626        }
627        let y = Array1::from_vec(points);
628        let (value, derivative) =
629            ctn_response_bases_at(y.view(), knots.view(), 3, None).expect("bases with tails");
630
631        // Row 0 / row 1 are the two anchors; every exterior row is the anchor
632        // plus its own distance times the anchor's slope, and carries exactly
633        // the anchor's slope.
634        for (index, &point) in y.iter().enumerate().skip(2) {
635            let (anchor_row, anchor) = if point < y_lo { (0, y_lo) } else { (1, y_hi) };
636            let step = point - anchor;
637            for k in CTN_LOCATION_COLUMNS..value.ncols() {
638                let slope = derivative[[anchor_row, k]];
639                let expected = value[[anchor_row, k]] + step * slope;
640                assert!(
641                    (value[[index, k]] - expected).abs() <= 1.0e-12 * expected.abs().max(1.0),
642                    "column {k} at y={point} is {} not the affine continuation {expected}",
643                    value[[index, k]]
644                );
645                assert!(
646                    (derivative[[index, k]] - slope).abs() <= 1.0e-15,
647                    "column {k} at y={point} has slope {} not the boundary slope {slope}",
648                    derivative[[index, k]]
649                );
650            }
651        }
652
653        // Non-degeneracy: the continuation would be vacuous if the boundary
654        // derivative were zero, which is exactly the state this replaces.
655        let lower_slope: f64 = derivative
656            .row(0)
657            .iter()
658            .skip(CTN_LOCATION_COLUMNS)
659            .sum::<f64>();
660        let upper_slope: f64 = derivative
661            .row(1)
662            .iter()
663            .skip(CTN_LOCATION_COLUMNS)
664            .sum::<f64>();
665        assert!(
666            lower_slope > 1.0e-3 && upper_slope > 1.0e-3,
667            "the boundary derivatives the continuation is anchored at are degenerate: \
668             lower={lower_slope:.6e} upper={upper_slope:.6e}"
669        );
670    }
671
672    #[test]
673    fn response_bases_are_c1_across_both_boundary_knots_2600() {
674        // The invariant that separates "extrapolates" from "saturates" with no
675        // tolerance to argue about: `M_k` has no jump at the boundary. Before
676        // this, the jump was the whole boundary derivative (O(1) inside, exactly
677        // 0 outside).
678        let knots = tail_knots();
679        let delta = 1.0e-9_f64;
680        let y = Array1::from_vec(vec![-1.2 - delta, -1.2 + delta, 1.2 - delta, 1.2 + delta]);
681        let (value, derivative) =
682            ctn_response_bases_at(y.view(), knots.view(), 3, None).expect("bases across the knots");
683        for (outside, inside) in [(0usize, 1usize), (3usize, 2usize)] {
684            // Scale every comparison by the largest slope the interior row
685            // carries: the boundary derivative lives in one column, and the
686            // question is whether that column survives the crossing at all.
687            let scale = derivative
688                .row(inside)
689                .iter()
690                .fold(0.0_f64, |acc, value| acc.max(value.abs()));
691            assert!(
692                scale > 1.0e-3,
693                "the interior boundary derivative is already degenerate ({scale:.6e}); \
694                 there is nothing for the continuation to match"
695            );
696            for k in CTN_LOCATION_COLUMNS..value.ncols() {
697                let jump = (derivative[[outside, k]] - derivative[[inside, k]]).abs();
698                assert!(
699                    jump <= 1.0e-4 * scale,
700                    "M_{k} jumps by {jump:.6e} across a boundary knot (inside {:.6e}, \
701                     outside {:.6e}); before gam#2600's continuation this jump WAS the whole \
702                     boundary derivative",
703                    derivative[[inside, k]],
704                    derivative[[outside, k]]
705                );
706                let value_jump = (value[[outside, k]] - value[[inside, k]]).abs();
707                assert!(
708                    value_jump <= 1.0e-4 * scale.max(1.0),
709                    "I_{k} jumps by {value_jump:.6e} across a boundary knot"
710                );
711            }
712        }
713    }
714
715    #[test]
716    fn the_transform_is_strictly_increasing_and_unbounded_on_the_whole_line_2600() {
717        // The modelling consequence, on a feasible coefficient vector: with
718        // `α ≥ 0` (the Khatri-Rao monotonicity cone) the transform must run from
719        // −∞ to +∞ so that `F = Φ(h)` is a proper CDF. Before this it ran
720        // between two finite plateaux joined by the `1e-8` floor, so the model
721        // needed `Δy ~ 1e8` to spend its tail mass.
722        let knots = tail_knots();
723        let probe = Array1::from_vec(vec![-1.2, 1.2]);
724        let (probe_value, _) =
725            ctn_response_bases_at(probe.view(), knots.view(), 3, None).expect("endpoint bases");
726        let p_resp = probe_value.ncols();
727        // `α₀ = 0` and every shape coordinate 1: a feasible, strictly monotone
728        // transformation on the Khatri-Rao cone.
729        let mut alpha = Array1::from_elem(p_resp, 1.0);
730        alpha[0] = 0.0;
731        let (lower_basis, upper_basis) =
732            ctn_endpoint_bases(&Array2::<f64>::eye(p_resp - CTN_LOCATION_COLUMNS));
733        let floors = CtnRowFloors {
734            additive_offset: 0.0,
735            value_floor: 0.0,
736            lower_floor: 0.0,
737            upper_floor: 0.0,
738        };
739        let far = Array1::from_vec(vec![-1.0e3, -1.2, 0.0, 1.2, 1.0e3]);
740        let (value, derivative) =
741            ctn_response_bases_at(far.view(), knots.view(), 3, None).expect("far bases");
742        let mut previous = f64::NEG_INFINITY;
743        let mut extremes = Vec::new();
744        for row in 0..far.len() {
745            let value_row = value.row(row);
746            let derivative_row = derivative.row(row);
747            let geometry = ctn_row_geometry(
748                TransformationNormalParameterization::DirectAlpha,
749                alpha.view(),
750                bases(
751                    value_row.as_slice().expect("contiguous value row"),
752                    derivative_row.as_slice().expect("contiguous slope row"),
753                    lower_basis.as_slice().expect("contiguous"),
754                    upper_basis.as_slice().expect("contiguous"),
755                ),
756                floors,
757            );
758            assert!(
759                geometry.h > previous,
760                "h is not strictly increasing at y={}: {} <= {previous}",
761                far[row],
762                geometry.h
763            );
764            previous = geometry.h;
765            assert!(
766                geometry.h_prime > 1.0e-3,
767                "h' at y={} collapsed to {:.6e}; the exterior slope must be the boundary \
768                 derivative, not the monotonicity floor",
769                far[row],
770                geometry.h_prime
771            );
772            if row == 0 || row + 1 == far.len() {
773                extremes.push(geometry.h);
774            }
775        }
776        // `h` really does reach the far tails of the standard normal: at
777        // `|y| = 1e3` the latent is hundreds of sigma, not the `±U` plateau.
778        assert!(
779            extremes[0] < -1.0e2 && extremes[1] > 1.0e2,
780            "the transform is still bounded outside its knots: h(-1e3)={:.6e}, h(1e3)={:.6e}",
781            extremes[0],
782            extremes[1]
783        );
784    }
785
786    #[test]
787    fn response_bases_inside_the_knots_are_untouched_by_the_continuation_2600() {
788        // The fit only ever evaluates strictly inside the certified support
789        // (`ctn_response_knots` guards it by 0.1 % of the response span), so the
790        // continuation must be bit-identical there or it would silently move
791        // every fitted model.
792        let knots = tail_knots();
793        let interior = Array1::from_vec(vec![-1.2, -0.9, -0.3, 0.0, 0.4, 1.1, 1.2]);
794        let (interior_value, interior_derivative) =
795            ctn_response_bases_at(interior.view(), knots.view(), 3, None).expect("interior");
796        // Same points, evaluated in a batch that also contains exterior rows, so
797        // the continuation branch is definitely taken.
798        let mut mixed = interior.to_vec();
799        mixed.push(-9.0);
800        mixed.push(9.0);
801        let mixed = Array1::from_vec(mixed);
802        let (mixed_value, mixed_derivative) =
803            ctn_response_bases_at(mixed.view(), knots.view(), 3, None).expect("mixed");
804        for row in 0..interior.len() {
805            for k in 0..interior_value.ncols() {
806                assert_eq!(
807                    interior_value[[row, k]].to_bits(),
808                    mixed_value[[row, k]].to_bits(),
809                    "interior value moved at (row {row}, column {k})"
810                );
811                assert_eq!(
812                    interior_derivative[[row, k]].to_bits(),
813                    mixed_derivative[[row, k]].to_bits(),
814                    "interior derivative moved at (row {row}, column {k})"
815                );
816            }
817        }
818    }
819
820    #[test]
821    fn response_bases_apply_a_non_identity_transform() {
822        let knots = Array1::from_vec(vec![
823            -1.2, -1.2, -1.2, -1.2, -1.2, 0.0, 1.2, 1.2, 1.2, 1.2, 1.2,
824        ]);
825        let y = Array1::from_vec(vec![-0.3, 0.4]);
826        let (raw_value, _) = ctn_response_bases_at(y.view(), knots.view(), 3, None).expect("raw");
827        let p_shape = raw_value.ncols() - CTN_LOCATION_COLUMNS;
828        let transform = Array2::<f64>::eye(p_shape) * 2.0;
829        let (scaled, _) =
830            ctn_response_bases_at(y.view(), knots.view(), 3, Some(&transform)).expect("scaled");
831        for i in 0..2 {
832            assert_eq!(scaled[[i, 0]], 1.0);
833            for k in CTN_LOCATION_COLUMNS..scaled.ncols() {
834                assert!((scaled[[i, k]] - 2.0 * raw_value[[i, k]]).abs() < 1e-12);
835            }
836        }
837    }
838}