Skip to main content

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}