gam_models/wiggle.rs
1use crate::parameter_block::ParameterBlockInput;
2use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix};
3use gam_solve::pirls::LinearInequalityConstraints;
4use gam_terms::basis::ispline_function_penalties;
5use ndarray::{Array1, Array2, ArrayView1};
6use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug)]
9pub struct WiggleBlockConfig {
10 pub degree: usize,
11 pub num_internal_knots: usize,
12 pub penalty_order: usize,
13 pub double_penalty: bool,
14}
15
16/// Semantic identity of one canonical I-spline penalty block.
17///
18/// The order of these values is the smoothing-parameter order. Persisting the
19/// topology prevents inference code from guessing a derivative order from a
20/// lambda index or inventing a zero block when the guess is invalid.
21#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(tag = "kind", rename_all = "kebab-case")]
23pub enum WigglePenaltyBlockKind {
24 Roughness { derivative_order: usize },
25 NullspaceShrinkage { derivative_order: usize },
26}
27
28/// Complete semantic description of a realized monotone-wiggle penalty list.
29///
30/// `derivative_orders` is already canonicalized into the exact roughness-block
31/// order used by fitting: primary first, followed by deduplicated additional
32/// orders. `blocks` additionally records whether the primary roughness emitted
33/// a function-metric nullspace shrinkage coordinate. For example, an order-one
34/// anchored I-spline roughness is full rank, so `double_penalty=true` emits no
35/// synthetic ridge block.
36#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
37pub struct WigglePenaltyMetadata {
38 pub derivative_orders: Vec<usize>,
39 pub double_penalty: bool,
40 pub blocks: Vec<WigglePenaltyBlockKind>,
41}
42
43/// Exact matrices and nullities accompanying [`WigglePenaltyMetadata`].
44#[derive(Clone, Debug)]
45pub struct CanonicalWigglePenaltySet {
46 pub metadata: WigglePenaltyMetadata,
47 pub matrices: Vec<Array2<f64>>,
48 pub nullspace_dims: Vec<usize>,
49}
50
51#[derive(Clone)]
52pub(crate) struct SelectedWiggleBasis {
53 pub knots: Array1<f64>,
54 pub degree: usize,
55 pub block: ParameterBlockInput,
56 pub penalty_metadata: WigglePenaltyMetadata,
57}
58
59// gam#2695: the WARP knot generator. A monotone warp is composed onto a
60// β-dependent index, so a boundary knot's multiplicity is a step in the basis's
61// own derivative tower and therefore in the inner objective. Warp blocks build
62// their knots here; the clamped `gam_terms::basis::initializewiggle_knots_from_seed`
63// stays for bases evaluated on FIXED data — a response transform — where the
64// evaluation point never moves across that boundary.
65pub(crate) use gam_terms::basis::monotone_warp_knots_from_seed;
66
67/// The highest derivative of a composed warp's basis that the inner objective's
68/// own VALUE reads (gam#2695).
69///
70/// A composed warp is not evaluated on fixed data. It sits on the model's index,
71/// `q = q₀ + Σ_j βw_j·I_j(q₀)` with `q₀ = −η_t·e^{−η_ls}`, so `q₀` moves with β
72/// while the knots stay where the seed put them, and the objective differentiates
73/// the basis rather than merely evaluating it.
74///
75/// # Read off the row program, not argued
76///
77/// `sls_row_nll_wiggle` composes the basis into the row jet at two places, and
78/// the second is the one that sets this order:
79///
80/// ```text
81/// q₁ʷ = q₁ + Σ βw_j·I_j(q₁) stack [I, I′, I″, I‴, I⁗]
82/// m₁ = 1 + Σ βw_j·I′_j(q₁) stack [I′, I″, I‴, I⁗, …] <- SHIFTED BY ONE
83/// g = η_t′ + m₁·q̇₀, and ℓ ∋ −d·log g
84/// ```
85///
86/// `H = ∂²(−ℓ)/∂β²` is the order-2 coefficient of that jet, and `m₁`'s order-2
87/// coefficient reads its stack's slot 2 — which, because `m₁` is built from `I′`,
88/// is **`I‴`**. `Φ = ½ Σ g(λ(Z_JᵀHZ_J))` is a TERM OF THE OBJECTIVE, not a
89/// diagnostic about it — the inner NLL is `−ℓ + ½βᵀSβ − Φ` — so the objective's
90/// own value reads `I‴`, and a step in `I‴` is a jump in the function the trust
91/// region compares two points on.
92///
93/// # The order is measured on the FIT, because a unit fixture under-reported it
94///
95/// This constant was first landed as `2`, from
96/// `probe_2695_joint_hessian_across_an_interior_knot`: on that fixture `H` steps
97/// at degree 2 (ratio `1.00` at both `βw = 1e-6` and `βw = 3e-2`) and closes at
98/// degree 3 (ratio `100`). That fixture does not excite the `βw`-weighted `I‴`
99/// channel above its own resolution, so it cannot see the difference between
100/// degree 3 and degree 4, and reading the order off it alone was wrong.
101///
102/// The shipped witness does excite it. `Φ`'s response to the accepted step,
103/// swept down the trust ladder (`|Δφ| / |δ|∞` constant ⇒ smooth; `Δφ` constant
104/// ⇒ a jump):
105///
106/// ```text
107/// built at 3: |δ|∞ 1.573e-10 → 3.379e-13 Δφ −2.571e-7 … −2.599e-7 JUMP
108/// built at 4: |δ|∞ 2.037e-08 → 9.820e-12 ratio 13.76 … 12.22 smooth
109/// built at 5: |δ|∞ 1.032e-01 → 7.545e-02 ratio 6.01e-2 … 5.88e-2 smooth
110/// built at 6: |δ|∞ 4.608e-06 → 1.150e-06 ratio 8.77 … 8.86 smooth
111/// ```
112///
113/// Three decades of step size with `Δφ` pinned at `−2.6e-7` is a jump; four
114/// decades with `|Δφ|/|δ|∞` constant to three digits is a derivative. So the
115/// objective reads `I‴`, the order is **3**, and a composed warp is built at 4.
116///
117/// End to end, before any of this: at degree 2 the accept-test objective jumped
118/// by `2.976461e-1`, identically, at step norms from `1.436e-10` to `7.094e-13`,
119/// and the jump was Φ to twelve digits while `ℓ` and `½βᵀSβ` did not move at all.
120pub(crate) const COMPOSED_WARP_OBJECTIVE_BASIS_DERIVATIVE_ORDER: usize = 3;
121
122/// The smallest public degree at which a composed warp's basis is continuous to
123/// [`COMPOSED_WARP_OBJECTIVE_BASIS_DERIVATIVE_ORDER`], hence the smallest degree
124/// at which the inner objective is a function at all.
125///
126/// A degree-`d` I-spline is `C^{d−1}` at a knot of multiplicity one, so the
127/// requirement `d − 1 ≥ order` is what this returns. Nothing is chosen: the
128/// order comes from the objective and the `−1` from the spline.
129///
130/// # Why this RAISES the degree instead of refusing it
131///
132/// `5139b8977` derived the same floor and REFUSED below it. That was reverted in
133/// `3c530dbda` for a reason that still holds: a refusal breaks every working fit
134/// that asked for degree 2 and buys none of them a fit. The requirement is not a
135/// statement about what the user may ask for — it is a property of the objective
136/// the model then has to minimise, in the same class as "a B-spline basis needs
137/// at least `degree + 1` knots". So a composed warp is BUILT at this degree, the
138/// realised degree is what the block, the knots, the penalties and the saved
139/// metadata all carry, and the raise is logged rather than silent.
140#[inline]
141pub(crate) fn composed_warp_minimum_degree() -> usize {
142 COMPOSED_WARP_OBJECTIVE_BASIS_DERIVATIVE_ORDER + 1
143}
144
145#[inline]
146pub(crate) fn monotone_wiggle_internal_degree(degree: usize) -> Result<usize, String> {
147 // Public monotone-wiggle degree refers to the value basis. The low-level
148 // I-spline builder integrates a degree-`internal_degree` specification
149 // into a degree-`internal_degree + 1` value basis, so we subtract one here
150 // to keep the public degree and the per-span value degree aligned.
151 degree
152 .checked_sub(1)
153 .filter(|&internal_degree| internal_degree >= 1)
154 .ok_or_else(|| "monotone wiggle degree must be >= 2".to_string())
155}
156
157/// Build the exact ordered function-space penalty set for an anchored
158/// I-spline monotone wiggle.
159///
160/// `derivative_orders` must already be in the fitting order and contain no
161/// duplicates. The first order is the primary roughness; only its structural
162/// null space is eligible for the separate double-penalty coordinate. Every
163/// matrix comes from the canonical `C^T S_B C` function Gram, never a
164/// coefficient difference or identity metric.
165///
166/// # The assembled set always has a trivial joint null space (gam#2647)
167///
168/// A monotone link warp is not an ordinary smooth. It is composed onto a free
169/// index — `q = q₀ + w(q₀)` with `q₀ = −η_t·e^{−η_ls}` (binomial location-scale)
170/// or `q = η + w(η)` (binomial mean) — and the index carries its own free
171/// scale. That makes the warp's LINEAR direction a gauge rather than a shape:
172/// for any `s > 0`,
173///
174/// ```text
175/// (β_index, β_w) ↦ (β_index / s, β_w + (s−1)·ℓ), B·ℓ = (u − left)
176/// ```
177///
178/// reproduces the same `q` on every row inside the knot hull, because
179/// `q₀/s + (s−1)(q₀/s − left) + w(q₀/s)` is `q₀` up to the constant the warp can
180/// itself absorb. The likelihood is exactly invariant along that orbit. The
181/// PENALTY is not: the index block is penalized, so `½βᵀSβ` falls like `1/s²`
182/// there. If `ℓ` is unpenalized the penalized objective therefore decreases
183/// monotonically in `s` and has **no minimiser** — the inner solve walks the
184/// orbit forever, `‖β‖∞` diverging while `½βᵀSβ` falls exactly like `‖β‖⁻²`,
185/// which is the measured signature on gam#2647.
186///
187/// An order-`k` anchored I-spline roughness has structural nullity `k − 1`, so
188/// every set whose smallest order exceeds one leaves `ℓ` free unless something
189/// closes it. `double_penalty` is a user knob and cannot be the thing that
190/// decides whether the criterion is bounded below, so the closure below is
191/// unconditional: after assembling the requested roughness blocks, the JOINT
192/// null space of the set (at unit smoothing) is computed in the function metric
193/// and, when non-trivial, one shrinkage coordinate spanning it is appended.
194///
195/// This is the same treatment — and the same argument — the binomial
196/// location-scale log-σ block already receives unconditionally in
197/// `build_binomial_threshold_and_scale_blocks`, where `(β_t, β_ls) ↦ (c·β_t,
198/// β_ls + ln c)` is the exactly analogous index-scale gauge and an identity
199/// shrinkage penalty is appended that the caller never asked for and cannot
200/// switch off. The wiggle block simply never received it.
201///
202/// It is a **no-op on every already-well-posed configuration**: the shipped
203/// default (`orders = [1, 2, 3]`) contains the order-one roughness, which is
204/// full rank on the anchored basis, so the joint null space is already trivial
205/// and nothing is appended; likewise whenever `double_penalty` already closed a
206/// single-order set. Only configurations whose criterion is otherwise unbounded
207/// gain a coordinate, and that coordinate's strength is chosen by REML like any
208/// other.
209pub fn canonical_wiggle_function_penalties(
210 knots: &Array1<f64>,
211 degree: usize,
212 derivative_orders: &[usize],
213 double_penalty: bool,
214) -> Result<CanonicalWigglePenaltySet, String> {
215 if derivative_orders.is_empty() {
216 return Err("wiggle penalty metadata requires at least one derivative order".to_string());
217 }
218 if derivative_orders.contains(&0) {
219 return Err("wiggle penalty derivative orders must all be positive".to_string());
220 }
221 for (index, &order) in derivative_orders.iter().enumerate() {
222 if derivative_orders[..index].contains(&order) {
223 return Err(format!(
224 "wiggle penalty derivative order {order} is duplicated in canonical metadata"
225 ));
226 }
227 }
228
229 let internal_degree = monotone_wiggle_internal_degree(degree)?;
230 let mut blocks = Vec::new();
231 let mut matrices = Vec::new();
232 let mut nullspace_dims = Vec::new();
233 for (index, &derivative_order) in derivative_orders.iter().enumerate() {
234 let penalties = ispline_function_penalties(
235 knots.view(),
236 internal_degree,
237 derivative_order,
238 index == 0 && double_penalty,
239 )
240 .map_err(|error| error.to_string())?;
241 blocks.push(WigglePenaltyBlockKind::Roughness { derivative_order });
242 matrices.push(penalties.roughness);
243 nullspace_dims.push(penalties.roughness_nullspace_dim);
244 if let Some(nullspace_shrinkage) = penalties.nullspace_shrinkage {
245 blocks.push(WigglePenaltyBlockKind::NullspaceShrinkage { derivative_order });
246 matrices.push(nullspace_shrinkage);
247 nullspace_dims.push(0);
248 }
249 }
250
251 // Gauge closure (gam#2647) — see the type-level note above. The joint null
252 // space is read off the SUM of the assembled blocks at unit smoothing, which
253 // is `null(Σ S_j) = ⋂_j null(S_j)` exactly because every `S_j` is PSD, so a
254 // direction survives only when NO requested block penalizes it. Reading the
255 // sum (rather than the primary alone) is what makes this both complete —
256 // a multi-order set is judged by what it collectively leaves free — and
257 // idempotent: when `double_penalty` already emitted a shrinkage coordinate,
258 // that coordinate is inside the sum, the intersection is empty, and nothing
259 // is appended. Tagged with the PRIMARY derivative order, so a single-order
260 // set with `double_penalty = true` and one with `double_penalty = false`
261 // produce the same topology, which is the point.
262 //
263 // Each block enters the sum divided by its OWN mean diagonal. Without that
264 // the test would not be "does any block penalize this direction" but "does
265 // any block penalize it comparably to the stiffest block present": an
266 // order-3 roughness has eigenvalues orders above an order-1 roughness on the
267 // same knots, so on the shipped default (`orders = [1, 2, 3]`) the order-1
268 // block — which is the one that closes the gauge — would sit inside the
269 // rank tolerance of the order-3 block and the sum would report a null space
270 // that does not exist, appending a coordinate to a configuration that never
271 // needed one. A per-block scale is the only thing that makes the
272 // intersection `⋂_j null(S_j)` the quantity actually computed, and it is
273 // derived from the matrices rather than chosen.
274 let primary_order = derivative_orders[0];
275 let joint_dim = matrices.first().map_or(0, |m| m.nrows());
276 if joint_dim > 0 {
277 let mut joint = Array2::<f64>::zeros((joint_dim, joint_dim));
278 for matrix in &matrices {
279 let mean_diagonal =
280 (0..joint_dim).map(|i| matrix[[i, i]].abs()).sum::<f64>() / joint_dim as f64;
281 if !(mean_diagonal > 0.0) || !mean_diagonal.is_finite() {
282 continue;
283 }
284 joint.scaled_add(1.0 / mean_diagonal, matrix);
285 }
286 // Failure here is propagated rather than swallowed. Skipping the closure
287 // on a set we cannot certify as closed would ship exactly the criterion
288 // this exists to prevent, and silently — a refusal naming the reason is
289 // the strictly more useful outcome.
290 let function_gram = gam_terms::basis::ispline_function_gram(knots.view(), internal_degree)
291 .map_err(|error| {
292 format!(
293 "wiggle gauge closure needs the I-spline function Gram to decide whether the \
294 assembled penalty set leaves a reparameterization of the index unpenalized, \
295 and it could not be built: {error}"
296 )
297 })?;
298 if let Some(gauge_shrinkage) =
299 gam_terms::basis::function_space_nullspace_shrinkage(&joint, &function_gram).map_err(
300 |error| {
301 format!(
302 "wiggle gauge closure could not resolve the joint null space of the \
303 assembled penalty set: {error}"
304 )
305 },
306 )?
307 {
308 blocks.push(WigglePenaltyBlockKind::NullspaceShrinkage {
309 derivative_order: primary_order,
310 });
311 matrices.push(gauge_shrinkage);
312 nullspace_dims.push(0);
313 }
314 }
315
316 Ok(CanonicalWigglePenaltySet {
317 metadata: WigglePenaltyMetadata {
318 derivative_orders: derivative_orders.to_vec(),
319 double_penalty,
320 blocks,
321 },
322 matrices,
323 nullspace_dims,
324 })
325}
326
327fn buildwiggle_block_input_from_canonical_penalties(
328 seed: ArrayView1<'_, f64>,
329 knots: &Array1<f64>,
330 degree: usize,
331 canonical: &CanonicalWigglePenaltySet,
332) -> Result<ParameterBlockInput, String> {
333 let design = monotone_wiggle_basis_from_knots(seed, knots, degree)?;
334 let p = design.ncols();
335 if p == 0 {
336 return Err("wiggle basis has no free monotone columns".to_string());
337 }
338 if canonical.matrices.len() != canonical.nullspace_dims.len()
339 || canonical.matrices.len() != canonical.metadata.blocks.len()
340 {
341 return Err(
342 "canonical wiggle penalty matrices, nullities, and topology disagree".to_string(),
343 );
344 }
345 for (index, matrix) in canonical.matrices.iter().enumerate() {
346 if matrix.dim() != (p, p) {
347 return Err(format!(
348 "canonical I-spline penalty block {index} is {}x{} but wiggle design has {p} columns",
349 matrix.nrows(),
350 matrix.ncols(),
351 ));
352 }
353 }
354 Ok(ParameterBlockInput {
355 design: DesignMatrix::Dense(DenseDesignMatrix::from(design)),
356 offset: Array1::zeros(seed.len()),
357 penalties: canonical
358 .matrices
359 .iter()
360 .cloned()
361 .map(crate::model_types::PenaltySpec::Dense)
362 .collect(),
363 nullspace_dims: canonical.nullspace_dims.clone(),
364 initial_log_lambdas: None,
365 initial_beta: Some(Array1::zeros(p)),
366 })
367}
368
369pub fn buildwiggle_block_input_from_knots(
370 seed: ArrayView1<'_, f64>,
371 knots: &Array1<f64>,
372 degree: usize,
373 penalty_order: usize,
374 double_penalty: bool,
375) -> Result<ParameterBlockInput, String> {
376 buildwiggle_block_input_from_orders(seed, knots, degree, &[penalty_order], double_penalty)
377}
378
379/// Build a monotone I-spline block carrying the COMPLETE requested penalty set.
380///
381/// Callers that want several derivative orders must come through here rather
382/// than building a primary-order block and appending the rest: the gauge closure
383/// in [`canonical_wiggle_function_penalties`] is a property of the assembled set
384/// (it asks what the set collectively leaves unpenalized), so it can only be
385/// decided once, on the final list. Assembling in two stages would judge the
386/// primary order alone and could both add a coordinate the later orders made
387/// unnecessary and miss one they left open.
388///
389/// The emitted order is unchanged from the previous two-stage assembly —
390/// primary roughness, its optional double-penalty coordinate, then the extra
391/// orders in the order given — so persisted penalty topologies are unaffected.
392pub fn buildwiggle_block_input_from_orders(
393 seed: ArrayView1<'_, f64>,
394 knots: &Array1<f64>,
395 degree: usize,
396 derivative_orders: &[usize],
397 double_penalty: bool,
398) -> Result<ParameterBlockInput, String> {
399 let canonical =
400 canonical_wiggle_function_penalties(knots, degree, derivative_orders, double_penalty)?;
401 buildwiggle_block_input_from_canonical_penalties(seed, knots, degree, &canonical)
402}
403
404pub fn buildwiggle_block_input_from_seed(
405 seed: ArrayView1<'_, f64>,
406 cfg: &WiggleBlockConfig,
407) -> Result<(ParameterBlockInput, Array1<f64>), String> {
408 let knots = monotone_warp_knots_from_seed(seed, cfg.degree, cfg.num_internal_knots)?;
409 let block = buildwiggle_block_input_from_knots(
410 seed,
411 &knots,
412 cfg.degree,
413 cfg.penalty_order,
414 cfg.double_penalty,
415 )?;
416 Ok((block, knots))
417}
418
419pub(crate) fn monotone_wiggle_basis_from_knots(
420 seed: ArrayView1<'_, f64>,
421 knots: &Array1<f64>,
422 degree: usize,
423) -> Result<Array2<f64>, String> {
424 monotone_wiggle_basis_with_derivative_order(seed, knots, degree, 0)
425}
426
427/// A monotone warp basis and every derivative order of it, as ONE function on
428/// all of `ℝ`.
429///
430/// # What a warp needs that a shape basis does not (gam#2695)
431///
432/// A shape basis is evaluated on fixed data: its evaluation point never moves,
433/// so how smooth it is at a knot is invisible. A WARP is composed onto the
434/// model's own index — `q = q₀ + Σ_j βw_j·I_j(q₀)` with
435/// `q₀ = −η_t·e^{−η_ls}` — so `q₀` moves with β while the knots stay where the
436/// seed put them, and the inner objective differentiates the composition:
437///
438/// ```text
439/// ℓ reads w′ (the event Jacobian q̇ = (1 + w′)·r)
440/// ∇ℓ reads w″
441/// H reads w‴ and Φ = ½Σ g(λ(Z_JᵀHZ_J)) reads H
442/// ∇Φ = ∂H/∂β reads w⁗
443/// ```
444///
445/// `Φ` is part of the objective the trust region accepts on, so a STEP in any
446/// of `w′ … w‴` is a step in the objective, and `actual/predicted` cannot
447/// approach `1` at any step size. The warp must therefore be `C³`, and each
448/// column is `C^{degree−1−m}` at a knot of multiplicity `m`.
449///
450/// # Why this is one line now
451///
452/// It used to be the clamped I-spline inside its knot hull plus a hand-written
453/// linear tail outside it, because `create_ispline_dense` is constant outside
454/// the hull and a constant-extended I-spline has a corner there. The tail moved
455/// the corner from order 1 to order 2 and no further, and it could not move it
456/// further: at a clamped edge most columns have `I′ = 0` and `I″ ≠ 0`, and a
457/// monotone `C²` extension of a column with `I′(e) = 0 > I″(e)` does not exist
458/// (`I′` would have to go negative immediately). The corner is the boundary
459/// knot's MULTIPLICITY, not the extrapolation rule, and the cure is a knot
460/// vector whose ends are simple — [`gam_terms::basis::monotone_warp_knots`] —
461/// evaluated by [`gam_terms::basis::ispline_ramp_basis_dense`], which is the
462/// same ramp on all of `ℝ` and reproduces the clamped convention bit for bit
463/// wherever that convention was right.
464pub fn monotone_wiggle_basis_with_derivative_order(
465 seed: ArrayView1<'_, f64>,
466 knots: &Array1<f64>,
467 degree: usize,
468 derivative_order: usize,
469) -> Result<Array2<f64>, String> {
470 let internal_degree = monotone_wiggle_internal_degree(degree)?;
471 gam_terms::basis::ispline_ramp_basis_dense(
472 seed,
473 knots.view(),
474 internal_degree,
475 derivative_order,
476 )
477 .map_err(|error| error.to_string())
478}
479
480/// The `β ≥ 0` system a monotone-wiggle block is subject to, as an explicit
481/// dense system with unit rows.
482///
483/// This is the ONE definition of that cone. Both the constraint set the
484/// blockwise QP enforces ([`monotone_wiggle_nonnegative_constraints`]) and any
485/// line-search barrier that clips a step inside it are built from here, so the
486/// two cannot be constructed from different systems — a barrier hook that
487/// re-derives the cone by hand is how gam#2719's coordinate loop ended up with
488/// a `1e-10` tolerance on the iterate and none at all on the step, while the QP
489/// enforcing the identical rows worked to `1e-8`.
490pub(crate) fn monotone_wiggle_nonnegative_system(
491 beta_dim: usize,
492) -> Option<LinearInequalityConstraints> {
493 if beta_dim == 0 {
494 return None;
495 }
496 let mut a = Array2::<f64>::zeros((beta_dim, beta_dim));
497 for i in 0..beta_dim {
498 a[[i, i]] = 1.0;
499 }
500 Some(LinearInequalityConstraints {
501 a,
502 b: Array1::zeros(beta_dim),
503 })
504}
505
506pub(crate) fn monotone_wiggle_nonnegative_constraints(
507 beta_dim: usize,
508) -> Option<gam_solve::pirls::ConstraintSet> {
509 monotone_wiggle_nonnegative_system(beta_dim).map(gam_solve::pirls::ConstraintSet::Dense)
510}
511
512pub(crate) fn validate_monotone_wiggle_beta_nonnegative<'a>(
513 beta: impl IntoIterator<Item = &'a f64>,
514 context: &str,
515) -> Result<(), String> {
516 for (idx, &value) in beta.into_iter().enumerate() {
517 if !value.is_finite() {
518 return Err(format!("{context} coefficient {idx} is non-finite"));
519 }
520 if value < -1e-12 {
521 return Err(format!(
522 "{context} coefficient {idx} is negative ({value:.3e}); monotone wiggle coefficients must be non-negative"
523 ));
524 }
525 }
526 Ok(())
527}
528
529/// Slack tolerance for the `beta >= 0` monotone-wiggle inequality constraints.
530///
531/// The constrained inner Newton/QP holds a binding coordinate at the boundary
532/// only up to its own KKT tolerance, so an accepted step can leave the active
533/// coordinate a few ULPs below zero (e.g. `-2e-9`). That is feasibility within
534/// the solver tolerance, not a genuine sign violation, so the post-update hook
535/// projects such coordinates back onto the non-negative cone (clamps them to
536/// exactly `0`) rather than failing the fit. The band matches the constrained
537/// blockwise solver's KKT tolerances (`1e-6 * scale + 1e-10`,
538/// `1e-10 * (1 + scale)`); anything more negative survives the projection and
539/// is rejected by [`validate_monotone_wiggle_beta_nonnegative`].
540pub(crate) const MONOTONE_WIGGLE_ACTIVE_SET_TOL: f64 = 1e-6;
541
542/// Project a monotone-wiggle coefficient vector onto the non-negative cone the
543/// `beta >= 0` constraints define, clamping coordinates the constrained solve
544/// left slightly negative (within [`MONOTONE_WIGGLE_ACTIVE_SET_TOL`]) to exactly
545/// `0`. Coordinates more negative than the tolerance are left untouched so the
546/// subsequent [`validate_monotone_wiggle_beta_nonnegative`] still rejects
547/// genuine sign violations.
548pub(crate) fn project_monotone_wiggle_beta_nonnegative(mut beta: Array1<f64>) -> Array1<f64> {
549 for value in beta.iter_mut() {
550 if *value < 0.0 && *value >= -MONOTONE_WIGGLE_ACTIVE_SET_TOL {
551 *value = 0.0;
552 }
553 }
554 beta
555}
556
557/// Resolve a requested wiggle penalty-order set into:
558///
559/// - the primary derivative order used by the monotone I-spline function
560/// roughness, and
561/// - the remaining function-derivative orders to append on the same basis.
562///
563/// The primary order is the smallest requested order. If the list is empty,
564/// `default_primary` is used. Zero is never silently dropped: it is not a
565/// roughness derivative and is therefore a typed configuration error. Extra
566/// orders are returned in original order, deduplicated, and exclude primary.
567pub fn split_wiggle_penalty_orders(
568 default_primary: usize,
569 penalty_orders: &[usize],
570) -> Result<(usize, Vec<usize>), String> {
571 if default_primary == 0 {
572 return Err("default wiggle penalty derivative order must be positive".to_string());
573 }
574 if penalty_orders.contains(&0) {
575 return Err("wiggle penalty derivative orders must all be positive".to_string());
576 }
577 let primary_order = penalty_orders
578 .iter()
579 .copied()
580 .min()
581 .unwrap_or(default_primary);
582 let mut extras = Vec::new();
583 for &order in penalty_orders {
584 if order == primary_order || extras.contains(&order) {
585 continue;
586 }
587 extras.push(order);
588 }
589 Ok((primary_order, extras))
590}
591
592pub(crate) fn select_wiggle_basis_from_seed(
593 seed: ArrayView1<'_, f64>,
594 cfg: &WiggleBlockConfig,
595 penalty_orders: &[usize],
596) -> Result<SelectedWiggleBasis, String> {
597 select_wiggle_basis_from_seed_with_knots(seed, cfg, penalty_orders, WarpKnotEnds::Clamped)
598}
599
600/// Which end condition the warp's knot vector carries.
601///
602/// A boundary knot of multiplicity `degree + 1` makes the ramp `C^{-1}` there:
603/// `I'` steps from `0` outside to its interior one-sided value inside, and `I''`
604/// steps by `2, 6, 12, 20` at degree `2, 3, 4, 5`. That step reaches the inner
605/// objective through `H` (gam#2695), so a warp the objective differentiates
606/// needs [`WarpKnotEnds::Simple`].
607///
608/// [`WarpKnotEnds::Clamped`] is not a second opinion about the mathematics — it
609/// is where a subsystem's SAVED-MODEL runtime still reconstructs its deviation
610/// on the clamped convention (the BMS anchored-cubic replay and the
611/// marginal-slope deviation runtime both do) and has to move to the ramp
612/// definition before its knots can. Each one is its own piece of work; doing it
613/// half-way would leave a fit and its replay reading different functions, which
614/// is the fault this issue is about.
615#[derive(Clone, Copy, Debug, PartialEq, Eq)]
616pub(crate) enum WarpKnotEnds {
617 Clamped,
618 Simple,
619}
620
621pub(crate) fn select_wiggle_basis_from_seed_with_knots(
622 seed: ArrayView1<'_, f64>,
623 cfg: &WiggleBlockConfig,
624 penalty_orders: &[usize],
625 ends: WarpKnotEnds,
626) -> Result<SelectedWiggleBasis, String> {
627 let (primary_order, extra_orders) =
628 split_wiggle_penalty_orders(cfg.penalty_order, penalty_orders)?;
629 let mut derivative_orders = Vec::with_capacity(1 + extra_orders.len());
630 derivative_orders.push(primary_order);
631 derivative_orders.extend(extra_orders);
632 // REALISED DEGREE (gam#2695). A simple-ended warp is one the inner objective
633 // COMPOSES rather than evaluates, so its basis has to be continuous to
634 // `COMPOSED_WARP_OBJECTIVE_BASIS_DERIVATIVE_ORDER` for that objective to be a
635 // function of β at all. The floor is tied to `Simple` ends and not applied to
636 // `Clamped` ones deliberately: at a boundary knot of multiplicity `degree + 1`
637 // the ramp is `C^{-1}` at EVERY degree, so raising the degree there buys
638 // nothing — a clamped composed warp becomes admissible by moving its ends
639 // first, which is the work `WarpKnotEnds` already names and tracks.
640 let degree = match ends {
641 WarpKnotEnds::Simple => {
642 let minimum = composed_warp_minimum_degree();
643 if cfg.degree < minimum {
644 log::info!(
645 "[warp-degree] composed monotone warp requested degree {} and is built at \
646 {minimum}: the inner objective reads the basis's derivative of order {} \
647 (H is the order-2 coefficient of the row jet and reaches the basis through \
648 m1 = 1 + sum betaw_j B'_j, i.e. the tower SHIFTED BY ONE, so it consumes \
649 the basis's THIRD derivative; and Phi = 1/2 sum g(lambda(Z_J^T H Z_J)) is a \
650 term of the objective, not a diagnostic about it), while a degree-d \
651 I-spline is only C^(d-1) at a simple knot — so degree {} leaves the \
652 objective discontinuous across every knot the index crosses (gam#2695)",
653 cfg.degree,
654 COMPOSED_WARP_OBJECTIVE_BASIS_DERIVATIVE_ORDER,
655 cfg.degree,
656 );
657 minimum
658 } else {
659 cfg.degree
660 }
661 }
662 WarpKnotEnds::Clamped => cfg.degree,
663 };
664 let knots = match ends {
665 WarpKnotEnds::Simple => {
666 monotone_warp_knots_from_seed(seed, degree, cfg.num_internal_knots)?
667 }
668 WarpKnotEnds::Clamped => {
669 gam_terms::basis::initializewiggle_knots_from_seed(seed, degree, cfg.num_internal_knots)?
670 }
671 };
672 let canonical =
673 canonical_wiggle_function_penalties(&knots, degree, &derivative_orders, cfg.double_penalty)?;
674 let block = buildwiggle_block_input_from_canonical_penalties(seed, &knots, degree, &canonical)?;
675 Ok(SelectedWiggleBasis {
676 knots,
677 degree,
678 block,
679 penalty_metadata: canonical.metadata,
680 })
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use crate::model_types::PenaltySpec;
687 use gam_terms::basis::initializewiggle_knots_from_seed;
688 use ndarray::Array1;
689
690 fn dense_penalty(spec: &PenaltySpec) -> &Array2<f64> {
691 match spec {
692 PenaltySpec::Dense(m) => m,
693 other => panic!("expected Dense penalty, got {other:?}"),
694 }
695 }
696
697 fn is_symmetric(m: &Array2<f64>) -> bool {
698 let n = m.nrows();
699 if m.ncols() != n {
700 return false;
701 }
702 for i in 0..n {
703 for j in 0..n {
704 if (m[[i, j]] - m[[j, i]]).abs() > 1e-12 {
705 return false;
706 }
707 }
708 }
709 true
710 }
711
712 // ---- monotone_wiggle_internal_degree ----
713
714 #[test]
715 fn internal_degree_rejects_degree_below_two() {
716 // degree 0 and 1 yield internal_degree < 1 -> Err with the documented message.
717 for d in [0usize, 1] {
718 let err = monotone_wiggle_internal_degree(d).unwrap_err();
719 assert_eq!(err, "monotone wiggle degree must be >= 2");
720 }
721 }
722
723 #[test]
724 fn internal_degree_is_degree_minus_one_for_valid_degrees() {
725 // degree >= 2 -> Ok(degree - 1): the per-span value degree aligned to the
726 // public value-basis degree.
727 assert_eq!(monotone_wiggle_internal_degree(2).unwrap(), 1);
728 assert_eq!(monotone_wiggle_internal_degree(3).unwrap(), 2);
729 assert_eq!(monotone_wiggle_internal_degree(10).unwrap(), 9);
730 }
731
732 // ---- buildwiggle_block_input_from_knots (driven via seed for valid knots) ----
733
734 fn build(double_penalty: bool, penalty_order: usize) -> (ParameterBlockInput, usize) {
735 // A spread-out seed so knot generation yields several monotone columns.
736 let seed = Array1::linspace(0.0, 1.0, 40);
737 let cfg = WiggleBlockConfig {
738 degree: 3,
739 num_internal_knots: 5,
740 penalty_order,
741 double_penalty,
742 };
743 let knots =
744 initializewiggle_knots_from_seed(seed.view(), cfg.degree, cfg.num_internal_knots)
745 .expect("knot init");
746 let block = buildwiggle_block_input_from_knots(
747 seed.view(),
748 &knots,
749 cfg.degree,
750 cfg.penalty_order,
751 cfg.double_penalty,
752 )
753 .expect("build block");
754 let p = block.design.ncols();
755 (block, p)
756 }
757
758 #[test]
759 fn single_penalty_block_shapes_and_invariants() {
760 let (block, p) = build(false, 2);
761 assert!(p >= 2, "expected multiple monotone columns, got p={p}");
762 // Offset is zeros with length = seed length.
763 assert_eq!(block.offset.len(), 40);
764 assert!(block.offset.iter().all(|&v| v == 0.0));
765 // initial_beta is Some(zeros(p)).
766 let beta = block.initial_beta.as_ref().expect("initial_beta");
767 assert_eq!(beta.len(), p);
768 assert!(beta.iter().all(|&v| v == 0.0));
769 // One ROUGHNESS penalty, plus the unconditional gauge-closure
770 // coordinate (gam#2647): an order-two roughness leaves the linear warp
771 // free, and the linear warp is the index scale, not a shape. This
772 // assertion used to read `== 1`, which is exactly the shape of the
773 // defect — a warp block shipped with an unpenalized reparameterization
774 // direction, so the penalized criterion had no minimiser.
775 assert_eq!(block.penalties.len(), 2);
776 assert_eq!(block.nullspace_dims.len(), 2);
777 // The exact function-derivative Gram is p x p and symmetric.
778 let s = dense_penalty(&block.penalties[0]);
779 assert_eq!(s.dim(), (p, p));
780 assert!(is_symmetric(s));
781 // The anchored I-spline excludes the constant polynomial, so the
782 // order-two derivative null space contains only the linear direction.
783 assert_eq!(block.nullspace_dims[0], 1);
784 // The closure coordinate penalizes a null space of its own dimension 0.
785 assert_eq!(block.nullspace_dims[1], 0);
786 }
787
788 /// Smallest generalized eigenvalue of `Σ_j S_j` against the I-spline
789 /// function Gram, relative to the largest — i.e. how close the assembled
790 /// penalty set comes to leaving a whole function direction free.
791 fn relative_joint_nullity_margin(
792 knots: &Array1<f64>,
793 degree: usize,
794 orders: &[usize],
795 double_penalty: bool,
796 ) -> f64 {
797 use faer::Side;
798 use gam_linalg::faer_ndarray::FaerEigh;
799
800 let canonical = canonical_wiggle_function_penalties(knots, degree, orders, double_penalty)
801 .expect("canonical wiggle penalties");
802 let dim = canonical.matrices[0].nrows();
803 // Per-block normalization, matching the closure. The fitted penalty is
804 // `Σ_j λ_j S_j` with each `λ_j > 0` chosen independently by REML, so the
805 // set leaves a direction free iff EVERY block does — `⋂_j null(S_j)`,
806 // which is weight-independent. Summing the raw matrices asks a different
807 // and wrong question: an order-4 roughness dominates an order-1 one by
808 // five orders on these knots, so the raw sum reports as "free" every
809 // direction that is merely penalized much more weakly than the stiffest
810 // block, and the shrinkage coordinate — whose scale is the function
811 // Gram, not a high derivative — looks like nothing next to it.
812 let mut joint = Array2::<f64>::zeros((dim, dim));
813 for matrix in &canonical.matrices {
814 let mean_diagonal =
815 (0..dim).map(|i| matrix[[i, i]].abs()).sum::<f64>() / dim as f64;
816 if !(mean_diagonal > 0.0) || !mean_diagonal.is_finite() {
817 continue;
818 }
819 joint.scaled_add(1.0 / mean_diagonal, matrix);
820 }
821 let internal_degree = monotone_wiggle_internal_degree(degree).expect("internal degree");
822 let gram = gam_terms::basis::ispline_function_gram(knots.view(), internal_degree)
823 .expect("I-spline function Gram");
824 // Whiten by the function metric so the ratio is a statement about
825 // FUNCTIONS, not about the coefficient chart: `G^{-1/2} S G^{-1/2}`.
826 let (gvals, gvecs) = gram.eigh(Side::Lower).expect("Gram eigh");
827 let gmax = gvals.iter().copied().fold(0.0_f64, f64::max);
828 let mut g_inv_sqrt = Array2::<f64>::zeros((dim, dim));
829 for k in 0..dim {
830 let lam = gvals[k].max(1e-14 * gmax);
831 let scale = 1.0 / lam.sqrt();
832 let vk = gvecs.column(k);
833 for i in 0..dim {
834 for j in 0..dim {
835 g_inv_sqrt[[i, j]] += scale * vk[i] * vk[j];
836 }
837 }
838 }
839 let whitened = g_inv_sqrt.dot(&joint).dot(&g_inv_sqrt);
840 let (svals, _) = whitened.eigh(Side::Lower).expect("whitened penalty eigh");
841 let hi = svals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
842 let lo = svals.iter().copied().fold(f64::INFINITY, f64::min);
843 lo / hi.max(f64::MIN_POSITIVE)
844 }
845
846 /// gam#2647, stated as the invariant rather than as one fixture.
847 ///
848 /// A monotone link warp is composed onto a free index, so any function
849 /// direction the assembled penalty set leaves unpenalized is a
850 /// reparameterization the index can absorb for free — the penalized
851 /// criterion then has no minimiser and the inner solve diverges along the
852 /// orbit. The invariant is therefore: **for every configuration this crate
853 /// can emit, the assembled set has a trivial joint null space.** Checked in
854 /// the function metric so the verdict cannot be manufactured by a coefficient
855 /// rescale.
856 ///
857 /// Before the fix, every `double_penalty = false` row here (and every
858 /// `[2]`/`[2,3]` row regardless) had a joint null space of dimension ≥ 1,
859 /// i.e. a margin at machine zero.
860 #[test]
861 fn wiggle_penalty_set_always_closes_its_own_null_space_2647() {
862 let seed = Array1::linspace(0.0, 1.0, 60);
863 for degree in [2usize, 3, 4] {
864 let knots = initializewiggle_knots_from_seed(seed.view(), degree, 5)
865 .expect("knot init for the invariant sweep");
866 let max_order = degree; // value degree = degree, so order <= degree is represented
867 let mut order_sets: Vec<Vec<usize>> = Vec::new();
868 for primary in 1..=max_order {
869 order_sets.push(vec![primary]);
870 }
871 if max_order >= 2 {
872 order_sets.push((1..=max_order).collect());
873 order_sets.push((2..=max_order).collect());
874 }
875 for orders in &order_sets {
876 for double_penalty in [false, true] {
877 let margin =
878 relative_joint_nullity_margin(&knots, degree, orders, double_penalty);
879 assert!(
880 margin > 1e-10,
881 "degree {degree}, orders {orders:?}, double_penalty={double_penalty}: \
882 the assembled wiggle penalty set leaves a function direction free \
883 (smallest/largest whitened penalty eigenvalue = {margin:.6e}). That \
884 direction is a reparameterization of the index the warp is composed \
885 onto, so the penalized criterion is unbounded below along it (gam#2647)."
886 );
887 }
888 }
889 }
890 }
891
892 /// The gauge closure must be **invisible** to every configuration that was
893 /// already well posed — most of all the shipped default.
894 ///
895 /// `WigglePenaltyConfig::cubic_triple_operator_default` is `degree = 3`,
896 /// `orders = [1, 2, 3]`, `double_penalty = true`. Order one is full rank on
897 /// the anchored basis (`roughness_nullspace_dim = order − 1 = 0`), so that
898 /// set already leaves nothing free and the closure must append nothing: the
899 /// emitted topology has to stay exactly three roughness blocks, in order,
900 /// with no shrinkage coordinate anywhere. Asserted on the topology rather
901 /// than on a count so a coordinate appearing in the middle is caught too.
902 ///
903 /// This is the assertion that would fail if the joint-null test were done
904 /// on the RAW sum instead of on the per-block-normalized one: the order-3
905 /// roughness dominates the order-1 roughness by orders of magnitude on these
906 /// knots, and an unnormalized sum reports a null space the set does not have.
907 #[test]
908 fn shipped_default_wiggle_topology_is_untouched_by_the_gauge_closure_2647() {
909 let seed = Array1::linspace(0.0, 1.0, 60);
910 let cfg = gam_spec::WigglePenaltyConfig::cubic_triple_operator_default();
911 let knots = initializewiggle_knots_from_seed(seed.view(), cfg.degree, cfg.num_internal_knots)
912 .expect("knot init for the shipped default");
913 let canonical = canonical_wiggle_function_penalties(
914 &knots,
915 cfg.degree,
916 &cfg.penalty_orders,
917 cfg.double_penalty,
918 )
919 .expect("shipped-default canonical penalties");
920 assert_eq!(
921 canonical.metadata.blocks,
922 vec![
923 WigglePenaltyBlockKind::Roughness {
924 derivative_order: 1
925 },
926 WigglePenaltyBlockKind::Roughness {
927 derivative_order: 2
928 },
929 WigglePenaltyBlockKind::Roughness {
930 derivative_order: 3
931 },
932 ],
933 "the gauge closure changed the shipped-default wiggle penalty topology"
934 );
935 assert_eq!(canonical.matrices.len(), 3);
936 assert_eq!(canonical.nullspace_dims, vec![0, 1, 2]);
937 }
938
939 /// The concrete gauge, named: an order-two roughness leaves the LINEAR warp
940 /// free, and the linear warp is exactly the index rescale
941 /// `(β_index, β_w) ↦ (β_index/s, β_w + (s−1)ℓ)`. The closure must charge for
942 /// it while the roughness alone does not.
943 #[test]
944 fn linear_warp_direction_is_free_under_roughness_and_charged_after_closure_2647() {
945 use faer::Side;
946 use gam_linalg::faer_ndarray::FaerEigh;
947
948 let seed = Array1::linspace(0.0, 1.0, 60);
949 let degree = 3usize;
950 let knots = initializewiggle_knots_from_seed(seed.view(), degree, 5).expect("knot init");
951 let canonical = canonical_wiggle_function_penalties(&knots, degree, &[2], false)
952 .expect("order-two canonical set");
953 assert_eq!(
954 canonical.matrices.len(),
955 2,
956 "order-two roughness must be accompanied by its gauge closure"
957 );
958 let roughness = &canonical.matrices[0];
959 let closure = &canonical.matrices[1];
960 let dim = roughness.nrows();
961
962 // ℓ: the coefficient vector of the linear warp, recovered as the
963 // roughness null direction (the anchored basis excludes constants, so
964 // the order-two null space is exactly the linear ramp).
965 let (rvals, rvecs) = roughness.eigh(Side::Lower).expect("roughness eigh");
966 let rmax = rvals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
967 let mut k_min = 0usize;
968 for k in 0..dim {
969 if rvals[k] < rvals[k_min] {
970 k_min = k;
971 }
972 }
973 let ell = rvecs.column(k_min).to_owned();
974 let rough_energy = ell.dot(&roughness.dot(&ell));
975 let closure_energy = ell.dot(&closure.dot(&ell));
976 assert!(
977 rough_energy <= 1e-10 * rmax,
978 "the linear warp must be free under an order-two roughness: ℓᵀSℓ = {rough_energy:.6e} \
979 against λ_max = {rmax:.6e}"
980 );
981 assert!(
982 closure_energy > 1e-8 * rmax.max(1.0),
983 "the gauge closure must charge for the linear warp: ℓᵀRℓ = {closure_energy:.6e}"
984 );
985 // And the free direction must be the one it charges MOST. Exact
986 // complementarity is deliberately not asserted: the shrinkage is
987 // `(G Z)(G Z)ᵀ` with `Z` spanning `null(S)` in the FUNCTION metric, so
988 // it annihilates the metric-generalized eigenvectors of `(S, G)` — not
989 // the ordinary coefficient-space eigenvectors of `S` used here, which
990 // are not `G`-orthogonal to `Z`. Measured on this fixture the null
991 // direction carries 2.35 against a worst range direction of 0.449, and
992 // demanding zero there would be asserting a property this construction
993 // (the one the `double_penalty` path has always used) does not have.
994 let range_energy = (0..dim)
995 .filter(|&k| rvals[k] > 1e-8 * rmax)
996 .map(|k| {
997 let v = rvecs.column(k);
998 v.dot(&closure.dot(&v)).abs()
999 })
1000 .fold(0.0_f64, f64::max);
1001 assert!(
1002 closure_energy > range_energy,
1003 "the closure must charge the FREE direction more than any direction the roughness \
1004 already penalizes: null energy {closure_energy:.6e} against max range energy \
1005 {range_energy:.6e}"
1006 );
1007 }
1008
1009 #[test]
1010 fn double_penalty_appends_nullspace_only_function_ridge() {
1011 let (block, p) = build(true, 2);
1012 assert!(p >= 2);
1013 // Order two has one structural null direction, so double penalty emits
1014 // one separate function-space shrinkage block.
1015 assert_eq!(block.penalties.len(), 2);
1016 assert_eq!(block.nullspace_dims.len(), 2);
1017 let ridge = dense_penalty(&block.penalties[1]);
1018 assert_eq!(ridge.dim(), (p, p));
1019 assert!(is_symmetric(ridge));
1020 assert!(
1021 (0..p).any(|i| (0..p).any(|j| i != j && ridge[[i, j]].abs() > 1e-12)),
1022 "function-metric null shrinkage must not collapse to eye(p)"
1023 );
1024 assert_eq!(block.nullspace_dims[1], 0);
1025 }
1026
1027 #[test]
1028 fn order_one_has_no_nullspace_ridge() {
1029 let (block, _) = build(true, 1);
1030 assert_eq!(block.penalties.len(), 1);
1031 assert_eq!(block.nullspace_dims, vec![0]);
1032 }
1033
1034 #[test]
1035 fn unsupported_derivative_order_is_rejected_not_clamped() {
1036 let seed = Array1::linspace(0.0, 1.0, 40);
1037 let knots = initializewiggle_knots_from_seed(seed.view(), 3, 5).expect("knot init");
1038 let error = match buildwiggle_block_input_from_knots(seed.view(), &knots, 3, 4, false) {
1039 Ok(_) => panic!("order above represented value degree must be rejected"),
1040 Err(error) => error,
1041 };
1042 assert!(error.contains("derivative"), "unexpected error: {error}");
1043 }
1044
1045 #[test]
1046 fn explicit_zero_penalty_order_is_rejected() {
1047 let error = split_wiggle_penalty_orders(2, &[0, 2]).unwrap_err();
1048 assert_eq!(
1049 error,
1050 "wiggle penalty derivative orders must all be positive"
1051 );
1052 }
1053}
1054
1055/// gam#2695 — the monotone warp basis is ONE `C^{degree−1}` function on `ℝ`.
1056///
1057/// The composed warp `q = q₀ + Σ_j βw_j·I_j(q₀)` is differentiated three times
1058/// by the joint-Newton machinery — `ℓ` reads `w′`, `∇ℓ` reads `w″`, and `H`
1059/// (which the Firth value `Φ = ½Σ g(λ(Z_JᵀHZ_J))` is a function of, inside the
1060/// accept test) reads `w‴` — in a state where `q₀` moves with β while the knots
1061/// stay where the seed put them. A basis that STEPS at any of those orders puts
1062/// that step into the objective, and `actual/predicted` then cannot approach
1063/// `1` at any step size.
1064///
1065/// These pins are stated on the basis itself, where the contract lives, rather
1066/// than on the fit that exposed it.
1067#[cfg(test)]
1068mod warp_basis_smoothness_2695_tests {
1069 use super::*;
1070 use gam_terms::basis::monotone_warp_knots;
1071 use ndarray::Array1;
1072
1073 const DEGREE: usize = 2;
1074
1075 /// The shipped `linkwiggle(internal_knots=2)` shape over `[-1, 2]`, built
1076 /// by the WARP generator: simple knots throughout, the grid continued by
1077 /// `degree` spans at each end. Degree 2 is deliberate here — this module
1078 /// pins the BASIS, whose contract is `C^{degree−1}`; whether a COMPOSED
1079 /// warp may be built at that degree is a separate question, and the answer
1080 /// is measured on the FIT rather than asserted here — see
1081 /// `a_degree_two_composed_warp_steps_even_on_the_warp_knot_vector_2695`.
1082 fn knots() -> Array1<f64> {
1083 monotone_warp_knots(-1.0, 2.0, DEGREE, 2).expect("warp knots")
1084 }
1085
1086 fn basis_at(x: f64, order: usize) -> Array1<f64> {
1087 let seed = Array1::from_elem(1, x);
1088 monotone_wiggle_basis_with_derivative_order(seed.view(), &knots(), DEGREE, order)
1089 .expect("warp basis")
1090 .row(0)
1091 .to_owned()
1092 }
1093
1094 /// The contract, at every knot and every order the objective reads.
1095 ///
1096 /// Scale-free: the one-sided gap must FALL with the step. A step leaves it
1097 /// flat, which is what the clamped vector does at order 2 — see
1098 /// `gam_terms::basis::ispline_ramp::tests::the_clamped_vector_steps_at_order_two_at_every_degree`
1099 /// for that negative control.
1100 #[test]
1101 fn every_derivative_below_the_degree_is_continuous_at_every_knot() {
1102 let knots = knots();
1103 for order in 0..DEGREE {
1104 for &knot in knots.iter() {
1105 let gap = |h: f64| -> f64 {
1106 let lo = basis_at(knot - h, order);
1107 let hi = basis_at(knot + h, order);
1108 lo.iter()
1109 .zip(hi.iter())
1110 .fold(0.0_f64, |acc, (a, b)| acc.max((a - b).abs()))
1111 };
1112 let coarse = gap(1.0e-3);
1113 let fine = gap(1.0e-6);
1114 assert!(
1115 fine <= coarse / 100.0 + 1.0e-12,
1116 "order {order} steps at knot {knot}: gap {coarse:.3e} at h=1e-3 and \
1117 {fine:.3e} at h=1e-6, a ratio of {:.2e} against the 1000x a continuous \
1118 derivative must give",
1119 coarse / fine.max(f64::MIN_POSITIVE),
1120 );
1121 }
1122 }
1123 }
1124
1125 /// Non-vacuity for the pin above: the basis MOVES across those knots, so
1126 /// "continuous" is not being satisfied by a table of zeros.
1127 #[test]
1128 fn the_basis_moves_materially_across_its_own_knots() {
1129 let knots = knots();
1130 let low = basis_at(knots[0] - 1.0, 0);
1131 let high = basis_at(knots[knots.len() - 1] + 1.0, 0);
1132 for j in 0..low.len() {
1133 assert!(
1134 (high[j] - low[j]).abs() > 0.5,
1135 "column {j} must traverse its whole ramp across the knot vector; got \
1136 {:.3e} -> {:.3e}",
1137 low[j],
1138 high[j],
1139 );
1140 }
1141 }
1142
1143 /// Value and derivative are one function: a central difference of the
1144 /// VALUE reproduces the reported DERIVATIVE, inside the knots and outside.
1145 #[test]
1146 fn the_value_and_its_reported_derivative_are_one_function() {
1147 let cbrt_eps = f64::EPSILON.cbrt();
1148 for &x in &[-6.0_f64, -2.5, -1.0, -0.25, 0.5, 1.25, 2.0, 3.0, 7.0] {
1149 let h = cbrt_eps * (1.0 + x.abs());
1150 let analytic = basis_at(x, 1);
1151 let plus = basis_at(x + h, 0);
1152 let minus = basis_at(x - h, 0);
1153 for j in 0..analytic.len() {
1154 // A degree-2 warp is `C¹` but not `C²`, so a difference
1155 // straddling a knot is only first-order accurate; keep the
1156 // bound at the straddling accuracy rather than the interior one.
1157 let fd = (plus[j] - minus[j]) / (2.0 * h);
1158 let tol = 1.0e-4 * (1.0 + analytic[j].abs());
1159 assert!(
1160 (fd - analytic[j]).abs() <= tol,
1161 "at x={x} column {j}: analytic I' = {:.9e} but a central difference of \
1162 the SAME basis's value is {fd:.9e}",
1163 analytic[j],
1164 );
1165 }
1166 }
1167 }
1168
1169 /// The warp stays a warp: every column is non-decreasing on all of `ℝ`, so
1170 /// `w = Σ βw_j I_j` with `βw ≥ 0` is monotone everywhere.
1171 #[test]
1172 fn every_column_is_non_decreasing_on_the_whole_line() {
1173 let grid = Array1::linspace(-8.0, 9.0, 341);
1174 let values = monotone_wiggle_basis_with_derivative_order(grid.view(), &knots(), DEGREE, 0)
1175 .expect("warp values");
1176 let slopes = monotone_wiggle_basis_with_derivative_order(grid.view(), &knots(), DEGREE, 1)
1177 .expect("warp slopes");
1178 for j in 0..values.ncols() {
1179 for i in 1..values.nrows() {
1180 assert!(
1181 values[[i, j]] >= values[[i - 1, j]] - 1.0e-12,
1182 "column {j} decreases between x={} and x={}: {} -> {}",
1183 grid[i - 1],
1184 grid[i],
1185 values[[i - 1, j]],
1186 values[[i, j]],
1187 );
1188 }
1189 for i in 0..slopes.nrows() {
1190 assert!(
1191 slopes[[i, j]] >= -1.0e-12,
1192 "column {j} has a negative slope {} at x={}",
1193 slopes[[i, j]],
1194 grid[i],
1195 );
1196 }
1197 }
1198 }
1199
1200 /// Outside its own support a ramp is EXACTLY `0` or `1`, with every
1201 /// derivative exactly zero — the property that lets the warp be extended to
1202 /// all of `ℝ` with no tail convention at all.
1203 #[test]
1204 fn each_column_is_flat_outside_its_own_support() {
1205 let knots = knots();
1206 for far in [knots[0] - 3.0, knots[knots.len() - 1] + 3.0] {
1207 let value = basis_at(far, 0);
1208 for order in 1..=3usize {
1209 let derivative = basis_at(far, order);
1210 for j in 0..derivative.len() {
1211 assert_eq!(
1212 derivative[j], 0.0,
1213 "column {j} order {order} at x={far} must be exactly zero"
1214 );
1215 }
1216 }
1217 for j in 0..value.len() {
1218 assert!(
1219 value[j] == 0.0 || value[j] == 1.0,
1220 "column {j} at x={far} must be exactly 0 or 1, got {}",
1221 value[j]
1222 );
1223 }
1224 }
1225 }
1226}
1227
1228/// gam#2695 — a COMPOSED warp is built at the degree its own objective requires.
1229///
1230/// The basis module above pins `C^{degree−1}`, a property of the basis. This
1231/// module pins the other half: which degree a warp the objective COMPOSES is
1232/// allowed to be built at, and that the answer is applied by construction rather
1233/// than by refusal.
1234#[cfg(test)]
1235mod composed_warp_degree_2695_tests {
1236 use super::*;
1237 use ndarray::Array1;
1238
1239 fn seed() -> Array1<f64> {
1240 Array1::from_shape_fn(24, |i| -1.5 + 3.0 * (i as f64) / 23.0)
1241 }
1242
1243 fn cfg(degree: usize) -> WiggleBlockConfig {
1244 WiggleBlockConfig {
1245 degree,
1246 num_internal_knots: 2,
1247 penalty_order: 2,
1248 double_penalty: false,
1249 }
1250 }
1251
1252 /// The floor is derived from two stated facts, so it must equal their
1253 /// composition rather than a literal that happens to agree today.
1254 #[test]
1255 fn the_minimum_degree_is_the_objective_order_plus_the_spline_loss() {
1256 assert_eq!(
1257 composed_warp_minimum_degree(),
1258 COMPOSED_WARP_OBJECTIVE_BASIS_DERIVATIVE_ORDER + 1,
1259 "a degree-d I-spline is C^(d-1) at a simple knot, so continuity to order k \
1260 needs d >= k + 1"
1261 );
1262 // Non-vacuity: the floor has to bind on the shipped witness, which asks
1263 // for `linkwiggle(degree=2, internal_knots=2)`.
1264 assert!(
1265 composed_warp_minimum_degree() > 2,
1266 "the floor must exclude degree 2 or the witness this issue is named for is \
1267 unaffected by it"
1268 );
1269 }
1270
1271 /// A simple-ended (composed) warp asked for a degree below the floor is
1272 /// BUILT at the floor, and every object it returns carries the realised
1273 /// degree — the knot vector's span count, the design's column count, and the
1274 /// degree the block reports to the saved model.
1275 #[test]
1276 fn a_composed_warp_below_the_floor_is_built_at_the_floor() {
1277 let minimum = composed_warp_minimum_degree();
1278 let seed = seed();
1279 for requested in 2..=minimum {
1280 let selected = select_wiggle_basis_from_seed_with_knots(
1281 seed.view(),
1282 &cfg(requested),
1283 &[2],
1284 WarpKnotEnds::Simple,
1285 )
1286 .expect("composed warp basis");
1287 assert_eq!(
1288 selected.degree,
1289 requested.max(minimum),
1290 "requested degree {requested}"
1291 );
1292 // `monotone_warp_knots` emits `num_internal_knots + 1` spans across
1293 // the range and continues the grid by `degree` spans at each end,
1294 // all simple — `num_internal_knots + 2 + 2·degree` knots. So the
1295 // knot count is a direct readout of the degree the basis was
1296 // actually built at.
1297 assert_eq!(
1298 selected.knots.len(),
1299 2 + 2 + 2 * selected.degree,
1300 "knot count must follow the REALISED degree, not the requested one"
1301 );
1302 // Column count is `num_internal_knots + degree` for this generator.
1303 assert_eq!(
1304 selected.block.design.ncols(),
1305 2 + selected.degree,
1306 "the design must be the realised degree's basis"
1307 );
1308 }
1309 }
1310
1311 /// Above the floor nothing moves: the realised degree is the requested one.
1312 #[test]
1313 fn a_composed_warp_at_or_above_the_floor_is_untouched() {
1314 let seed = seed();
1315 for requested in composed_warp_minimum_degree()..=composed_warp_minimum_degree() + 2 {
1316 let selected = select_wiggle_basis_from_seed_with_knots(
1317 seed.view(),
1318 &cfg(requested),
1319 &[2],
1320 WarpKnotEnds::Simple,
1321 )
1322 .expect("composed warp basis");
1323 assert_eq!(selected.degree, requested);
1324 }
1325 }
1326
1327 /// The floor is tied to SIMPLE ends and must not reach the clamped
1328 /// subsystems: at a boundary knot of multiplicity `degree + 1` the ramp is
1329 /// `C^{-1}` at every degree, so raising the degree there would change those
1330 /// fits while fixing nothing. Their route to admissibility is moving their
1331 /// ends, which `WarpKnotEnds` already names.
1332 #[test]
1333 fn a_clamped_warp_keeps_the_degree_it_asked_for() {
1334 let seed = seed();
1335 let selected =
1336 select_wiggle_basis_from_seed_with_knots(seed.view(), &cfg(2), &[2], WarpKnotEnds::Clamped)
1337 .expect("clamped wiggle basis");
1338 assert_eq!(
1339 selected.degree, 2,
1340 "the composed-warp floor must not silently re-shape a clamped block"
1341 );
1342 }
1343}