gam_terms/basis/input_loc_derivatives.rs
1//! Input-location derivatives of basis design columns.
2//!
3//! # Piece 2 of the `LatentCoord` work-stream
4//!
5//! For a radial basis with centers `c_k ∈ ℝ^d` and a kernel `φ(r)` evaluated
6//! at `r = ‖t − c_k‖`,
7//!
8//! ```text
9//! Φ_{n,k} = φ(‖t_n − c_k‖)
10//! ∂Φ_{n,k}/∂t_{n,a} = φ'(r_{nk}) · (t_n − c_k)_a / r_{nk}
11//! = q(r_{nk}) · (t_n − c_k)_a
12//! ∂²Φ_{n,k}/∂t_a∂t_b = q(r_{nk}) · δ_ab
13//! + s(r_{nk}) · (t_n − c_k)_a (t_n − c_k)_b
14//! ```
15//!
16//! where `q(r) = φ'(r)/r` and `s(r) = (φ''(r) − q(r))/r²`.
17//!
18//! These two radial scalars are exactly the `(q, t)` pair already returned
19//! by [`crate::basis::RadialScalarKind::eval_design_triplet`], which
20//! the ψ-derivative path uses to assemble `∂Φ/∂ψ`. The closed-form Taylor
21//! expansions in that routine already cover the `r → 0` collision limits.
22//!
23//! For tensor-product (`te`, `ti`) bases, the chain rule factors across
24//! axes — if axis `a` carries basis `B_a(t_a)`, then
25//!
26//! ```text
27//! Φ(t) = ⊗_a B_a(t_a)
28//! ∂Φ/∂t_a = (⊗_{b≠a} B_b(t_b)) ⊗ B_a'(t_a)
29//! ```
30//!
31//! For periodic / sphere bases, the input is a manifold coordinate `θ ∈ ℝ/2πℤ`
32//! (or `θ ∈ S²` parameterised by `(lat, lon)`). The chain rule passes
33//! through the wrap topology by evaluating the kernel against the signed
34//! chord (wrap-distance) and supplying the Jacobian `dr/dt` of that chord —
35//! identical to the chart-based Jacobian used by the periodic B-spline
36//! evaluator.
37//!
38//! The functions in this module are written in the style of the existing
39//! basis evaluators (caller-supplied output buffers, `BasisError` results)
40//! so they slot directly into the `LatentCoordValues::design_gradient_wrt_t`
41//! call site in `crate::latent` without an additional
42//! materialization layer.
43
44use ndarray::{Array1, Array3, ArrayView2};
45
46use crate::basis::{BasisError, MaternNu};
47
48// =========================================================================
49// Kernel parameter bundle
50// =========================================================================
51
52/// Closed-form parameterisation of the radial families supported by the
53/// input-location derivative routines below.
54///
55/// This is a thin convenience over [`RadialScalarKind`] (which itself is a
56/// crate-internal enum carrying the same parameters). The public layer here
57/// is kept as a separate type so that downstream consumers — including the
58/// Python pyffi layer that will surface `LatentCoord` — can construct kernel
59/// descriptors without poking at `pub(crate)` items.
60#[derive(Debug, Clone)]
61pub enum RadialInputKernel {
62 /// Matérn isotropic kernel with closed-form `(½, 3⁄2, 5⁄2, 7⁄2, 9⁄2)`-ν.
63 Matern { length_scale: f64, nu: MaternNu },
64 /// Hybrid Duchon kernel `||w||^(2p) · (κ² + ||w||²)^s`.
65 DuchonHybrid {
66 length_scale: f64,
67 p_order: usize,
68 s_order: usize,
69 dim: usize,
70 },
71 /// Pure scale-free Duchon kernel (single polyharmonic block of the
72 /// given order). Equivalent to `DuchonHybrid` with `s_order = 0` and no
73 /// finite length scale.
74 DuchonPure {
75 block_order: usize,
76 p_order: usize,
77 s_order: usize,
78 dim: usize,
79 },
80 /// Thin-plate spline kernel with explicit length-scale (used by the
81 /// 1-D thin-plate streaming path; for the general d-D thin-plate this
82 /// coincides with the polyharmonic Duchon kernel of order `m_d`).
83 ThinPlate { length_scale: f64, dim: usize },
84}
85
86impl RadialInputKernel {
87 /// Ambient input dimension `d` (the kernel argument length).
88 pub const fn dim(&self) -> usize {
89 match self {
90 RadialInputKernel::Matern { .. } => {
91 // Matérn is ambient-dimension agnostic in `q, t`; the caller
92 // is responsible for matching `centers.ncols()` to the data
93 // dimensionality. We return the conventional sentinel `0`
94 // here so consumers can short-circuit a dimension cross-check
95 // on the centers themselves.
96 0
97 }
98 RadialInputKernel::DuchonHybrid { dim, .. }
99 | RadialInputKernel::DuchonPure { dim, .. }
100 | RadialInputKernel::ThinPlate { dim, .. } => *dim,
101 }
102 }
103}
104
105// =========================================================================
106// Contraction helper (mirrors LatentCoordValues::contract_gradient)
107// =========================================================================
108
109/// Contract `(n_obs, n_centers)` upstream gradient against an
110/// `(n_obs, n_centers, d)` jet into a flat `(n_obs · d)` gradient w.r.t.
111/// the latent input `t`.
112///
113/// This is the closed-form chain-rule reduction used by both the inner
114/// Newton step and the backward pyffi entrypoint. See
115/// [`crate::latent::LatentCoordValues::contract_gradient`] for
116/// the canonical signature.
117pub fn contract_input_loc_gradient(
118 grad_phi: ArrayView2<'_, f64>,
119 jet: &Array3<f64>,
120) -> Result<Array1<f64>, BasisError> {
121 let n_obs = jet.shape()[0];
122 let n_centers = jet.shape()[1];
123 let d = jet.shape()[2];
124 if grad_phi.shape() != [n_obs, n_centers] {
125 crate::bail_dim_basis!(
126 "contract_input_loc_gradient: grad_phi shape {:?} != expected {:?}",
127 grad_phi.shape(),
128 [n_obs, n_centers]
129 );
130 }
131 let mut grad_t = Array1::<f64>::zeros(n_obs * d);
132 for n in 0..n_obs {
133 for a in 0..d {
134 let mut acc = 0.0_f64;
135 for k in 0..n_centers {
136 acc += grad_phi[[n, k]] * jet[[n, k, a]];
137 }
138 grad_t[n * d + a] = acc;
139 }
140 }
141 Ok(grad_t)
142}
143
144// =========================================================================
145// Tests (type-level documentation; NOT executed in this work-stream).
146// =========================================================================
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::basis::{RadialScalarKind, duchon_partial_fraction_coeffs};
152 use ndarray::array;
153
154 /// Project a `RadialInputKernel` onto the internal `RadialScalarKind`
155 /// enum so the radial-jet routines can be reused verbatim by the
156 /// divergence-witness tests.
157 fn into_scalar_kind(kernel: &RadialInputKernel) -> RadialScalarKind {
158 match kernel {
159 RadialInputKernel::Matern { length_scale, nu } => RadialScalarKind::Matern {
160 length_scale: *length_scale,
161 nu: *nu,
162 },
163 RadialInputKernel::DuchonHybrid {
164 length_scale,
165 p_order,
166 s_order,
167 dim,
168 } => {
169 let kappa = 1.0 / length_scale.max(1e-300);
170 let coeffs = duchon_partial_fraction_coeffs(*p_order, *s_order, kappa);
171 RadialScalarKind::Duchon {
172 length_scale: *length_scale,
173 p_order: *p_order,
174 s_order: *s_order,
175 dim: *dim,
176 coeffs,
177 }
178 }
179 RadialInputKernel::DuchonPure {
180 block_order,
181 p_order,
182 s_order,
183 dim,
184 } => RadialScalarKind::PureDuchon {
185 block_order: *block_order,
186 p_order: *p_order,
187 s_order: *s_order,
188 dim: *dim,
189 },
190 RadialInputKernel::ThinPlate { length_scale, dim } => RadialScalarKind::ThinPlate {
191 length_scale: *length_scale,
192 dim: *dim,
193 },
194 }
195 }
196
197 #[test]
198 fn contract_input_loc_gradient_matches_einsum() {
199 let jet = Array3::from_shape_vec(
200 (2, 3, 2),
201 vec![
202 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
203 ],
204 )
205 .unwrap();
206 let grad_phi = array![[1.0_f64, 0.0, 1.0], [0.0, 1.0, 0.0]];
207 let out = contract_input_loc_gradient(grad_phi.view(), &jet).unwrap();
208 // n=0: a=0 → 1*1 + 0*3 + 1*5 = 6; a=1 → 1*2 + 0*4 + 1*6 = 8
209 // n=1: a=0 → 0*7 + 1*9 + 0*11 = 9; a=1 → 0*8 + 1*10 + 0*12 = 10
210 assert_eq!(out[0], 6.0);
211 assert_eq!(out[1], 8.0);
212 assert_eq!(out[2], 9.0);
213 assert_eq!(out[3], 10.0);
214 }
215
216 // ----------------------------------------------------------------
217 // Degenerate-collision tests (F1–F5)
218 //
219 // These do not invoke any executable test runner here (they compile-
220 // check only under `cargo check --all-features --all-targets`); the
221 // intent is to lock in the contract that divergent kernels surface a
222 // `BasisError::DegenerateAtCollision` instead of a silent zero, and
223 // that a finite-difference probe at ε just away from the collision
224 // produces a large value consistent with the analytic divergence.
225 // ----------------------------------------------------------------
226
227 #[test]
228 fn matern_half_finite_difference_diverges_near_collision() {
229 // φ(r) = exp(−r); q = −exp(−r)/r → −∞ as r → 0+.
230 // At r = ε the design gradient component along the axis is
231 // q·s_axis = −exp(−ε)/ε · ε = −exp(−ε) ≈ −1, but the per-r
232 // scalar q itself blows up at ~1/ε, which is the witness for the
233 // divergence flagged by F1.
234 let kernel = RadialInputKernel::Matern {
235 length_scale: 1.0,
236 nu: MaternNu::Half,
237 };
238 let kind = into_scalar_kind(&kernel);
239 let eps = 1e-8_f64;
240 let (_, q, _) = kind
241 .eval_design_triplet(eps)
242 .expect("ν=1/2 at r=ε is finite");
243 assert!(
244 q.abs() > 1e6,
245 "expected divergent q for Matérn ν=1/2 near r=0, got {q}"
246 );
247 }
248
249 #[test]
250 fn thin_plate_collision_2d_finite_difference_diverges() {
251 // φ(r) = (r/ℓ)² log(r/ℓ); q = (1/ℓ²)(2 log(r/ℓ) + 1) → −∞.
252 let kernel = RadialInputKernel::ThinPlate {
253 length_scale: 1.0,
254 dim: 2,
255 };
256 let kind = into_scalar_kind(&kernel);
257 let eps = 1e-10_f64;
258 let (_, q, _) = kind
259 .eval_design_triplet(eps)
260 .expect("TPS dim=2 at r=ε is finite");
261 assert!(
262 q.abs() > 10.0,
263 "expected large |q| for TPS dim=2 near r=0, got {q}"
264 );
265 }
266}