Skip to main content

gam_terms/smooth/
term_design.rs

1// Term-collection design construction (#1521): the `build_term_collection_design`
2// subgraph relocated DOWN from `gam-models`
3// (`fit_orchestration/drivers/design_construction.rs`) into `gam_terms::smooth`,
4// where all of its callees and output types already live. This breaks the old
5// families -> fit_orchestration::drivers back-edge for design construction.
6//
7// This is a child module of `gam_terms::smooth` (not an `include!`d flat file),
8// so `use super::*` inherits the whole smooth-module import + definition surface
9// (prelude imports, `term_specs.rs` spec/design machinery, the
10// `structure_analysis` re-exports). Bodies are byte-identical to the gam-models
11// original except: (1) `gam_terms::basis::` paths rewritten to `crate::basis::`
12// for the in-crate boundary, and (2) the three entry points that staying
13// gam-models drivers still call are `pub` (re-exported from `smooth.rs`).
14use super::*;
15
16use super::shape_constraints::{
17    linear_constraints_from_lower_bounds_global, merge_linear_constraints_global,
18};
19use super::structure_analysis::smooth_has_frozen_identifiability;
20use crate::basis::{
21    ConstantCurvatureIdentifiability, MaternIdentifiability, MeasureJetIdentifiability,
22    SphericalSplineIdentifiability, orthogonality_transform_for_design,
23};
24use gam_linalg::matrix::{CoefficientTransformOperator, RandomEffectOperator};
25use ndarray::ArrayView1;
26
27/// Empirical L² mass of a scalar basis function under the uniform measure on
28/// the observed rows. A linear term has one realized basis column `b`; using
29/// `G = n⁻¹ bᵀb` makes its shrinkage energy `β²G = n⁻¹‖bβ‖²`, a property of
30/// the fitted function values rather than of the arbitrary coefficient scale.
31fn linear_function_mass(column: ArrayView1<'_, f64>, term_name: &str) -> Result<f64, BasisError> {
32    if column.is_empty() {
33        crate::bail_invalid_basis!(
34            "linear term '{term_name}' cannot define a function-space penalty on zero rows"
35        );
36    }
37    let scale = column.iter().copied().map(f64::abs).fold(0.0_f64, f64::max);
38    if !scale.is_finite() {
39        crate::bail_invalid_basis!(
40            "linear term '{term_name}' has a non-finite realized design column"
41        );
42    }
43    if scale == 0.0 {
44        crate::bail_invalid_basis!(
45            "linear term '{term_name}' is identically zero and cannot carry a recoverable effect"
46        );
47    }
48    let scaled_mean_square = column
49        .iter()
50        .map(|&value| {
51            let normalized = value / scale;
52            normalized * normalized
53        })
54        .sum::<f64>()
55        / column.len() as f64;
56    let mass = scale * scale * scaled_mean_square;
57    if !mass.is_finite() || mass <= 0.0 {
58        crate::bail_invalid_basis!(
59            "linear term '{term_name}' has an invalid empirical function mass {mass}"
60        );
61    }
62    Ok(mass)
63}
64
65pub fn build_term_collection_design_inner(
66    data: ArrayView2<'_, f64>,
67    spec: &TermCollectionSpec,
68) -> Result<TermCollectionDesign, BasisError> {
69    let policy = gam_runtime::resource::ResourcePolicy::default_library();
70    build_term_collection_design_inner_with_policy(data, spec, &policy)
71}
72
73/// Build a planned term collection while preserving the caller's resource
74/// policy through the actual basis realization. The policy must reach the
75/// [`BasisWorkspace`]: using it only while lowering the formula spec leaves a
76/// later spatial build free to reverse the routing decision under the library
77/// default.
78pub fn build_term_collection_design_inner_with_policy(
79    data: ArrayView2<'_, f64>,
80    spec: &TermCollectionSpec,
81    policy: &gam_runtime::resource::ResourcePolicy,
82) -> Result<TermCollectionDesign, BasisError> {
83    use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator};
84
85    let n = data.nrows();
86    let p_intercept = usize::from(!term_collection_has_anchored_bspline(spec));
87    let p_lin = spec.linear_terms.len();
88
89    // Smooth construction, random-effect construction, and linear-column
90    // extraction are independent at this stage. Run them concurrently, but keep
91    // each result in spec order so the final global layout remains stable:
92    // [intercept | linear | random_effects | smooth].
93    let (smooth_raw_result, (random_blocks_result, linear_block_result)) = rayon::join(
94        || {
95            let mut ws = crate::basis::BasisWorkspace::with_policy(policy.clone());
96            build_smooth_design_withworkspace_unvalidated(data, &spec.smooth_terms, &mut ws)
97        },
98        || {
99            rayon::join(
100                || {
101                    spec.random_effect_terms
102                        .par_iter()
103                        .map(|term| build_random_effect_block(data, term))
104                        .collect::<Result<Vec<_>, _>>()
105                },
106                || -> Result<Option<Array2<f64>>, BasisError> {
107                    if p_lin == 0 {
108                        return Ok(None);
109                    }
110
111                    let linear_columns = (0..p_lin)
112                        .into_par_iter()
113                        .map(|j| {
114                            let linear = &spec.linear_terms[j];
115                            // `:` interactions carry multiple feature columns; the
116                            // materialized column is their elementwise product
117                            // (a plain main effect has a single column), gated by
118                            // any categorical-level indicators for a factor-aware
119                            // `factor:x` expansion. `realized_design_column`
120                            // validates bounds and is the single authority every
121                            // design path (this one, the incremental realizer in
122                            // `build_term_collection_fixed_blocks`, and the
123                            // marginal-slope rank check) shares so they agree on
124                            // every interaction.
125                            linear
126                                .realized_design_column(data)
127                                .map_err(BasisError::InvalidInput)
128                        })
129                        .collect::<Result<Vec<_>, _>>()?;
130
131                    let mut out = Array2::<f64>::zeros((n, p_lin));
132                    for (j, column) in linear_columns.iter().enumerate() {
133                        out.column_mut(j).assign(column);
134                    }
135                    Ok(Some(out))
136                },
137            )
138        },
139    );
140
141    let smooth_raw = smooth_raw_result?;
142    let random_blocks = random_blocks_result?;
143    let linear_block = linear_block_result?;
144    // Reuse the TRAINING-time mass when this spec has already been through
145    // `freeze_term_collection_from_design` (predict/rebuild calls always pass
146    // the frozen `resolvedspec`). Recomputing from `block.column(j)` here would
147    // use whatever rows THIS call happens to be building over — a held-out
148    // grid, a handful of group/class anchors, a single test row — where a
149    // covariate that varies fine across the training set can easily look
150    // constant by chance. Only the very first, fit-time build (before the
151    // spec is frozen, so `frozen_function_mass` is still `None`) computes the
152    // mass fresh from `data`, which is right: at fit time `data` IS the
153    // training rows, so a genuine identifiability failure is real (#1561).
154    let linear_function_masses = match linear_block.as_ref() {
155        Some(block) => spec
156            .linear_terms
157            .iter()
158            .enumerate()
159            .map(|(j, term)| -> Result<Option<f64>, BasisError> {
160                if !term.double_penalty {
161                    return Ok(None);
162                }
163                if let Some(frozen_mass) = term.frozen_function_mass {
164                    return Ok(Some(frozen_mass));
165                }
166                linear_function_mass(block.column(j), &term.name).map(Some)
167            })
168            .collect::<Result<Vec<_>, _>>()?,
169        None => Vec::new(),
170    };
171
172    let (smooth, affine_offset) = apply_global_smooth_identifiability(
173        smooth_raw,
174        data,
175        &spec.linear_terms,
176        &spec.smooth_terms,
177    )?;
178
179    let p_rand: usize = random_blocks.iter().map(|b| b.num_groups).sum();
180    let p_smooth = smooth.total_smooth_cols();
181    let p_total = p_intercept + p_lin + p_rand + p_smooth;
182
183    let mut linear_ranges = Vec::<(String, Range<usize>)>::with_capacity(p_lin);
184    for (j, linear) in spec.linear_terms.iter().enumerate() {
185        let col = p_intercept + j;
186        // Column ranges are in the global (full) coordinate system:
187        // [intercept | linear | random_effects | smooth]
188        linear_ranges.push((linear.name.clone(), col..(col + 1)));
189    }
190
191    // Track random-effect column ranges in the global coordinate system.
192    // Global layout: [intercept(1) | linear(p_lin) | RE_0(q0) | RE_1(q1) | … | smooth(p_smooth)]
193    let mut random_effect_ranges =
194        Vec::<(String, Range<usize>)>::with_capacity(random_blocks.len());
195    let mut random_effect_levels = Vec::<(String, Vec<u64>)>::with_capacity(random_blocks.len());
196    let mut col_cursor = p_intercept + p_lin;
197    for block in &random_blocks {
198        let q = block.num_groups;
199        let end = col_cursor + q;
200        random_effect_ranges.push((block.name.clone(), col_cursor..end));
201        random_effect_levels.push((block.name.clone(), block.kept_levels.clone()));
202        col_cursor = end;
203    }
204
205    // ── Assemble the full DesignMatrix ────────────────────────────────
206    //
207    // Always use a BlockDesignOperator with per-term blocks.  The full
208    // (n, p_total) dense matrix is NEVER materialized:
209    //
210    //   Block 0:     Intercept — zero storage, implicit all-ones column
211    //   Block 1:     Linear terms — (n, p_lin) extracted from data
212    //   Blocks 2..k: Random-effect operators — O(n) one-hot, no dense storage
213    //   Blocks k+1..: Per-smooth-term dense blocks — each (n, p_term)
214    //
215    // Splitting smooth terms into per-term blocks means cross-block grams
216    // are small O(p_i × p_j) BLAS operations.  Tensor product terms with
217    // Kronecker structure become DesignBlock::Operator, avoiding the full
218    // n × ∏q_j materialization.
219
220    let mut blocks = Vec::<DesignBlock>::new();
221
222    // Block 0: intercept — zero storage. An anchored B-spline (one *or* two
223    // sided) consumes the absolute level at its pinned endpoint(s), so a free
224    // intercept would float the whole curve off the pin and violate the
225    // structural anchor.
226    if p_intercept == 1 {
227        blocks.push(DesignBlock::Intercept(n));
228    }
229
230    // Block 1: linear terms.
231    if let Some(lin_block) = linear_block {
232        blocks.push(DesignBlock::Dense(
233            gam_linalg::matrix::DenseDesignMatrix::from(lin_block),
234        ));
235    }
236
237    // Blocks: random-effect operators — O(n) implicit one-hot.
238    for block in &random_blocks {
239        let re_op = RandomEffectOperator::new(block.group_ids.clone(), block.num_groups);
240        blocks.push(DesignBlock::RandomEffect(Arc::new(re_op)));
241    }
242
243    // Blocks: per-smooth-term.  Each smooth term gets its own block so that
244    // cross-block grams are tiny. Tensor terms can stay operator-backed all
245    // the way from basis construction; dense smooth terms stay dense.
246    if p_smooth > 0 {
247        for term_design in &smooth.term_designs {
248            match term_design {
249                DesignMatrix::Dense(dense) => blocks.push(DesignBlock::Dense(dense.clone())),
250                DesignMatrix::Sparse(sparse) => blocks.push(DesignBlock::Sparse(sparse.clone())),
251            }
252        }
253    }
254
255    let design = assemble_term_collection_design_matrix(blocks)?;
256
257    let mut penalties = Vec::<BlockwisePenalty>::new();
258    let mut nullspace_dims = Vec::<usize>::new();
259    let mut penaltyinfo = Vec::<PenaltyBlockInfo>::new();
260    let mut dropped_penaltyinfo = Vec::<DroppedPenaltyBlockInfo>::new();
261    let mut coefficient_lower_bounds = Array1::<f64>::from_elem(p_total, f64::NEG_INFINITY);
262    let mut any_bounds = false;
263    let mut linear_constraintrows = Vec::<Array1<f64>>::new();
264    let mut linear_constraint_b = Vec::<f64>::new();
265
266    for (j, linear) in spec.linear_terms.iter().enumerate() {
267        let col = p_intercept + j;
268        if let Some(lb) = linear.coefficient_min {
269            let mut row = Array1::<f64>::zeros(p_total);
270            row[col] = 1.0;
271            linear_constraintrows.push(row);
272            linear_constraint_b.push(lb);
273        }
274        if let Some(ub) = linear.coefficient_max {
275            let mut row = Array1::<f64>::zeros(p_total);
276            row[col] = -1.0;
277            linear_constraintrows.push(row);
278            linear_constraint_b.push(-ub);
279        }
280    }
281
282    // Every non-intercept effect owns one independent REML coordinate. For a
283    // scalar linear basis `b_j`, the physical null-recovery penalty is its
284    // empirical function Gram `G_j = n⁻¹b_jᵀb_j`, so `β_j²G_j` is exactly the
285    // mean squared fitted effect. Under a harmless rescaling `b_j -> c b_j`,
286    // `β_j -> β_j/c`, the quadratic functional is unchanged. Keeping each
287    // term in its own one-column block also lets REML remove unsupported
288    // effects independently instead of forcing unrelated slopes to share λ.
289    for (j, linear) in spec.linear_terms.iter().enumerate() {
290        let Some(function_mass) = linear_function_masses.get(j).copied().flatten() else {
291            continue;
292        };
293        let col = p_intercept + j;
294        let global_index = penalties.len();
295        penalties.push(BlockwisePenalty::new(
296            col..(col + 1),
297            Array2::from_elem((1, 1), function_mass),
298        ));
299        nullspace_dims.push(0);
300        penaltyinfo.push(PenaltyBlockInfo {
301            global_index,
302            termname: Some(linear.name.clone()),
303            penalty: ActivePenaltyInfo {
304                source: PenaltySource::Other("LinearTermRidge".to_string()),
305                original_index: j,
306                effective_rank: 1,
307                normalization_scale: 1.0,
308                kronecker_factors: None,
309                structural_null_frame: None,
310            },
311        });
312    }
313
314    for (re_idx, (name, range)) in random_effect_ranges.iter().enumerate() {
315        if range.is_empty() || !spec.random_effect_terms[re_idx].penalized {
316            continue;
317        }
318        let block_size = range.len();
319        let global_index = penalties.len();
320        penalties.push(BlockwisePenalty::ridge(range.clone(), 1.0));
321        nullspace_dims.push(0);
322        penaltyinfo.push(PenaltyBlockInfo {
323            global_index,
324            termname: Some(name.clone()),
325            penalty: ActivePenaltyInfo {
326                source: PenaltySource::Other(format!("RandomEffectRidge({name})")),
327                original_index: re_idx,
328                effective_rank: block_size,
329                normalization_scale: 1.0,
330                kronecker_factors: None,
331                structural_null_frame: None,
332            },
333        });
334    }
335
336    if smooth.penaltyinfo.len() != smooth.penalties.len() {
337        gam_problem::bail_invalid_basis!(
338            "smooth penalty metadata mismatch: penalties={}, metadata={}",
339            smooth.penalties.len(),
340            smooth.penaltyinfo.len()
341        );
342    }
343    let smooth_start = p_intercept + p_lin + p_rand;
344    for ((bp_smooth, &ns), localinfo) in smooth
345        .penalties
346        .iter()
347        .zip(smooth.nullspace_dims.iter())
348        .zip(smooth.penaltyinfo.iter())
349    {
350        let global_index = penalties.len();
351        // Offset the per-term block range from smooth-local to model-global.
352        let offset_range =
353            (bp_smooth.col_range.start + smooth_start)..(bp_smooth.col_range.end + smooth_start);
354        let bp = if let Some(factors) = localinfo.penalty.kronecker_factors.as_ref() {
355            BlockwisePenalty::kronecker(offset_range, bp_smooth.local.clone(), factors.clone())
356                .with_op(bp_smooth.op.clone())
357        } else if matches!(
358            localinfo.penalty.source,
359            PenaltySource::Other(ref s) if s.starts_with("RandomEffectRidge")
360        ) {
361            BlockwisePenalty::ridge(offset_range, 1.0)
362        } else {
363            BlockwisePenalty::new(offset_range, bp_smooth.local.clone())
364                .with_op(bp_smooth.op.clone())
365        };
366        penalties.push(bp);
367        nullspace_dims.push(ns);
368        penaltyinfo.push(PenaltyBlockInfo {
369            global_index,
370            termname: localinfo.termname.clone(),
371            penalty: localinfo.penalty.clone(),
372        });
373    }
374    dropped_penaltyinfo.extend(smooth.dropped_penaltyinfo.iter().cloned());
375
376    assert_eq!(
377        penalties.len(),
378        nullspace_dims.len(),
379        "term-collection penalty/nullspace bookkeeping diverged"
380    );
381    assert_eq!(
382        penalties.len(),
383        penaltyinfo.len(),
384        "term-collection penalty metadata bookkeeping diverged"
385    );
386
387    if let Some(lb_smooth) = smooth.coefficient_lower_bounds.as_ref() {
388        let start = p_intercept + p_lin + p_rand;
389        coefficient_lower_bounds
390            .slice_mut(s![start..(start + p_smooth)])
391            .assign(lb_smooth);
392        any_bounds = true;
393    }
394    if let Some(lin_smooth) = smooth.linear_constraints.as_ref() {
395        let mut a_global = Array2::<f64>::zeros((lin_smooth.a.nrows(), p_total));
396        let start = p_intercept + p_lin + p_rand;
397        a_global
398            .slice_mut(s![.., start..(start + p_smooth)])
399            .assign(&lin_smooth.a);
400        for r in 0..a_global.nrows() {
401            linear_constraintrows.push(a_global.row(r).to_owned());
402            linear_constraint_b.push(lin_smooth.b[r]);
403        }
404    }
405
406    // Canonical constraint path: convert any explicit lower bounds into linear
407    // inequalities and merge into the global constraint matrix. This keeps fitting
408    // behavior independent of user-facing lower-bound options.
409    let lower_bound_constraints = if any_bounds {
410        linear_constraints_from_lower_bounds_global(&coefficient_lower_bounds)
411    } else {
412        None
413    };
414    let explicit_linear_constraints = if linear_constraintrows.is_empty() {
415        None
416    } else {
417        let mut a = Array2::<f64>::zeros((linear_constraintrows.len(), p_total));
418        for (i, row) in linear_constraintrows.iter().enumerate() {
419            a.row_mut(i).assign(row);
420        }
421        Some(LinearInequalityConstraints {
422            a,
423            b: Array1::from_vec(linear_constraint_b),
424        })
425    };
426    let linear_constraints =
427        merge_linear_constraints_global(explicit_linear_constraints, lower_bound_constraints)?;
428
429    Ok(TermCollectionDesign {
430        design,
431        affine_offset,
432        penalties,
433        nullspace_dims,
434        penaltyinfo,
435        dropped_penaltyinfo,
436        coefficient_lower_bounds: if any_bounds {
437            Some(coefficient_lower_bounds)
438        } else {
439            None
440        },
441        linear_constraints,
442        intercept_range: 0..p_intercept,
443        linear_ranges,
444        linear_function_masses,
445        random_effect_ranges,
446        random_effect_levels,
447        smooth,
448    })
449}
450
451/// Whether any smooth term carries an anchored B-spline endpoint (one *or* two
452/// sided). Such a term fixes the function's absolute level through its endpoint
453/// pin, so it becomes the model's level gauge: the global intercept is
454/// suppressed and the term is not additionally sum-to-zero centered.
455pub fn term_collection_has_anchored_bspline(spec: &TermCollectionSpec) -> bool {
456    spec.smooth_terms
457        .iter()
458        .any(|term| smooth_basis_has_anchored_bspline(&term.basis))
459}
460
461/// Whether any smooth term realizes an inhomogeneous endpoint anchor and thus
462/// contributes a fixed affine row offset.
463pub fn term_collection_has_nonzero_anchor(spec: &TermCollectionSpec) -> bool {
464    spec.smooth_terms
465        .iter()
466        .any(|term| smooth_basis_has_nonzero_anchor(&term.basis))
467}
468
469fn smooth_basis_has_nonzero_anchor(basis: &SmoothBasisSpec) -> bool {
470    match basis {
471        SmoothBasisSpec::ByVariable { inner, .. }
472        | SmoothBasisSpec::FactorSumToZero { inner, .. } => smooth_basis_has_nonzero_anchor(inner),
473        SmoothBasisSpec::BSpline1D { spec, .. } => spec.boundary_conditions.has_nonzero_anchor(),
474        SmoothBasisSpec::BySmooth { smooth, .. } => smooth_basis_has_nonzero_anchor(smooth),
475        SmoothBasisSpec::TensorBSpline { spec, .. } => spec
476            .marginalspecs
477            .iter()
478            .any(|marginal| marginal.boundary_conditions.has_nonzero_anchor()),
479        SmoothBasisSpec::FactorSmooth { spec } => {
480            spec.marginal.boundary_conditions.has_nonzero_anchor()
481        }
482        SmoothBasisSpec::ThinPlate { .. }
483        | SmoothBasisSpec::Sphere { .. }
484        | SmoothBasisSpec::ConstantCurvature { .. }
485        | SmoothBasisSpec::Matern { .. }
486        | SmoothBasisSpec::MeasureJet { .. }
487        | SmoothBasisSpec::Duchon { .. }
488        | SmoothBasisSpec::Pca { .. } => false,
489    }
490}
491
492fn smooth_basis_has_anchored_bspline(basis: &SmoothBasisSpec) -> bool {
493    match basis {
494        SmoothBasisSpec::ByVariable { inner, .. }
495        | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
496            smooth_basis_has_anchored_bspline(inner)
497        }
498        SmoothBasisSpec::BSpline1D { spec, .. } => {
499            bspline_conditions_have_anchor(&spec.boundary_conditions)
500        }
501        SmoothBasisSpec::BySmooth { smooth, .. } => smooth_basis_has_anchored_bspline(smooth),
502        SmoothBasisSpec::TensorBSpline { spec, .. } => spec
503            .marginalspecs
504            .iter()
505            .any(|marginal| bspline_conditions_have_anchor(&marginal.boundary_conditions)),
506        SmoothBasisSpec::FactorSmooth { .. }
507        | SmoothBasisSpec::ThinPlate { .. }
508        | SmoothBasisSpec::Sphere { .. }
509        | SmoothBasisSpec::ConstantCurvature { .. }
510        | SmoothBasisSpec::Matern { .. }
511        | SmoothBasisSpec::MeasureJet { .. }
512        | SmoothBasisSpec::Duchon { .. }
513        | SmoothBasisSpec::Pca { .. } => false,
514    }
515}
516
517fn bspline_conditions_have_anchor(conditions: &crate::basis::BSplineBoundaryConditions) -> bool {
518    conditions.has_anchor()
519}
520
521pub fn build_term_collection_design(
522    data: ArrayView2<'_, f64>,
523    spec: &TermCollectionSpec,
524) -> Result<TermCollectionDesign, BasisError> {
525    let policy = gam_runtime::resource::ResourcePolicy::default_library();
526    build_term_collection_design_with_policy(data, spec, &policy)
527}
528
529/// Policy-aware counterpart to [`build_term_collection_design`]. Center
530/// planning is identical; only the basis workspace's materialization contract
531/// differs.
532pub fn build_term_collection_design_with_policy(
533    data: ArrayView2<'_, f64>,
534    spec: &TermCollectionSpec,
535    policy: &gam_runtime::resource::ResourcePolicy,
536) -> Result<TermCollectionDesign, BasisError> {
537    validate_term_collection_finite_inputs(data, spec)?;
538    let mut planned_specs =
539        plan_joint_spatial_centers_for_term_blocks(data, &[spec.smooth_terms.clone()])?;
540    let planned_smooth_terms = planned_specs.pop().ok_or_else(|| {
541        BasisError::InvalidInput(
542            "joint spatial center planner returned no smooth terms for single-spec build"
543                .to_string(),
544        )
545    })?;
546    let mut planned_spec = spec.clone();
547    planned_spec.smooth_terms = planned_smooth_terms;
548    build_term_collection_design_inner_with_policy(data, &planned_spec, policy)
549}
550
551/// Exact analytic derivative of an affine term-collection realization.
552#[derive(Debug, Clone)]
553pub struct TermCollectionDerivativeDesign {
554    /// `∂design(x)/∂x_c`, aligned column-for-column with the value design.
555    pub design: Array2<f64>,
556    /// `∂affine_offset(x)/∂x_c` on the same rows.
557    pub affine_offset: Array1<f64>,
558}
559
560impl TermCollectionDerivativeDesign {
561    /// Evaluate `∂affine_offset/∂x_c + (∂design/∂x_c) * beta`.
562    pub fn apply(&self, beta: ArrayView1<'_, f64>) -> Result<Array1<f64>, BasisError> {
563        if beta.len() != self.design.ncols() {
564            crate::bail_dim_basis!(
565                "term-collection derivative coefficient length {} does not match design width {}",
566                beta.len(),
567                self.design.ncols()
568            );
569        }
570        if self.affine_offset.len() != self.design.nrows() {
571            crate::bail_dim_basis!(
572                "term-collection derivative affine offset has {} rows but derivative design has {}",
573                self.affine_offset.len(),
574                self.design.nrows()
575            );
576        }
577        if beta.iter().any(|value| !value.is_finite())
578            || self.affine_offset.iter().any(|value| !value.is_finite())
579        {
580            crate::bail_invalid_basis!(
581                "term-collection derivative coefficients and affine offset must be finite"
582            );
583        }
584        Ok(self.design.dot(&beta.to_owned()) + &self.affine_offset)
585    }
586}
587
588/// Build the EXACT analytic average-derivative realization of a term
589/// collection: `D = ∂design/∂x_c` plus the distinct fixed channel
590/// `d = ∂affine_offset/∂x_c`. The two are laid out on the same rows as
591/// `build_term_collection_design`, so the fitted derivative is `d + D * beta`
592/// (#1120/#2297).
593///
594/// This is a provably exact analytic derivative of the design, so the production
595/// path differentiates the model basis (a known analytic function) in closed form.
596///
597/// The construction differentiates each term's BASIS w.r.t. `deriv_col` and pushes
598/// the basis derivative through the SAME frozen identifiability/orthogonalization
599/// transform the value design uses. That transform is captured exactly by the
600/// per-term `metadata.identifiability_transform` from the value build: for every
601/// 1-D B-spline term the term's value design equals `B_raw(x) · M` where
602/// `M = metadata.identifiability_transform` is the composed
603/// `raw → boundary → sum-to-zero/joint-null/global-orthogonality` chart (it is a
604/// pure linear operator with no additive offset — sum-to-zero centering is a
605/// column reparameterization `Z`, not a subtracted mean). Differentiating gives
606/// `∂(design)/∂x = B'_raw(x) · M`. Additive constants (the intercept column) drop
607/// to zero; terms not involving `deriv_col`, and random-effect blocks, contribute
608/// zero columns; a linear main effect equal to `deriv_col` differentiates to 1.
609///
610/// # Supported structure
611///
612/// The realistic, tested usage is a single 1-D B-spline / P-spline smooth `s(x)`
613/// differentiated w.r.t. its one covariate. Supported: `SmoothBasisSpec::BSpline1D`
614/// (non-periodic) over the differentiated feature column, the intercept (zero),
615/// random-effect blocks (zero), smooths over other columns (zero), and linear
616/// terms (analytic product rule). Any other basis that actually involves
617/// `deriv_col` (tensor products, `ByVariable`, factor smooths, Duchon/thin-plate,
618/// sphere, periodic B-splines, …) returns a clear `Err` rather than a wrong
619/// number or a silent numeric approximation.
620pub fn build_term_collection_derivative_design(
621    data: ArrayView2<'_, f64>,
622    spec: &TermCollectionSpec,
623    deriv_col: usize,
624) -> Result<TermCollectionDerivativeDesign, BasisError> {
625    if deriv_col >= data.ncols() {
626        return Err(BasisError::InvalidInput(format!(
627            "average-derivative column {deriv_col} out of range for data with {} columns",
628            data.ncols()
629        )));
630    }
631
632    // The value design fixes the exact column layout and carries every term's
633    // realized identifiability transform in its metadata. Reusing it guarantees
634    // the derivative design aligns column-for-column with the fitted β.
635    let value = build_term_collection_design(data, spec)?;
636    let n = data.nrows();
637    let p_total = value.design.ncols();
638    let mut d = Array2::<f64>::zeros((n, p_total));
639    let mut affine_derivative = Array1::<f64>::zeros(n);
640
641    // Global layout: [intercept | linear | random_effects | smooth].
642    let p_intercept = value.intercept_range.len();
643    let p_lin = spec.linear_terms.len();
644    let p_rand: usize = value
645        .random_effect_ranges
646        .iter()
647        .map(|(_, range)| range.len())
648        .sum();
649
650    // Intercept column: constant ⇒ derivative 0 (already zero).
651    // Random-effect blocks: piecewise-constant group indicators ⇒ 0 (already zero).
652
653    // Linear terms: analytic product rule for the realized design column.
654    for (j, linear) in spec.linear_terms.iter().enumerate() {
655        let col = p_intercept + j;
656        let derivative = linear_term_derivative_column(data, linear, deriv_col)?;
657        if let Some(column) = derivative {
658            d.column_mut(col).assign(&column);
659        }
660    }
661
662    // Smooth terms: differentiate the basis of any term over `deriv_col`.
663    let smooth_start = p_intercept + p_lin + p_rand;
664    if value.smooth.terms.len() != spec.smooth_terms.len() {
665        return Err(BasisError::InvalidInput(format!(
666            "average-derivative design: value build produced {} smooth terms but spec has {}",
667            value.smooth.terms.len(),
668            spec.smooth_terms.len()
669        )));
670    }
671    for (idx, termspec) in spec.smooth_terms.iter().enumerate() {
672        let term_value = &value.smooth.terms[idx];
673        let feature_cols = smooth_term_feature_cols(termspec);
674        if !feature_cols.contains(&deriv_col) {
675            // Term does not involve the differentiated covariate ⇒ zero columns.
676            continue;
677        }
678        let (block, term_affine_derivative) =
679            smooth_term_first_derivative_block(data, termspec, term_value, deriv_col)?;
680        let range = (term_value.coeff_range.start + smooth_start)
681            ..(term_value.coeff_range.end + smooth_start);
682        if block.ncols() != range.len() {
683            return Err(BasisError::DimensionMismatch(format!(
684                "average-derivative design: smooth term '{}' derivative block has {} columns \
685                 but the fitted block spans {}",
686                termspec.name,
687                block.ncols(),
688                range.len()
689            )));
690        }
691        d.slice_mut(s![.., range]).assign(&block);
692        if let Some(term_offset) = term_affine_derivative {
693            if term_offset.len() != n {
694                return Err(BasisError::DimensionMismatch(format!(
695                    "average-derivative design: smooth term '{}' affine derivative has {} rows but the data has {n}",
696                    termspec.name,
697                    term_offset.len()
698                )));
699            }
700            affine_derivative += &term_offset;
701        }
702    }
703
704    Ok(TermCollectionDerivativeDesign {
705        design: d,
706        affine_offset: affine_derivative,
707    })
708}
709
710/// Analytic `∂/∂x_{deriv_col}` of a linear term's realized design column.
711///
712/// The realized column is `gate(x) · ∏_k x_{c_k}` where `gate` is a product of
713/// categorical-level indicators (constant w.r.t. a continuous covariate) and the
714/// `c_k` are the numeric feature columns. The product rule gives
715/// `∂/∂x_d = gate · Σ_{j: c_j = d} ∏_{k ≠ j} x_{c_k}`. Returns `None` when the
716/// term does not depend on `deriv_col` (its columns are zero).
717fn linear_term_derivative_column(
718    data: ArrayView2<'_, f64>,
719    linear: &LinearTermSpec,
720    deriv_col: usize,
721) -> Result<Option<Array1<f64>>, BasisError> {
722    let numeric_cols: Vec<usize> = if linear.categorical_levels.is_empty() {
723        linear.effective_feature_cols()
724    } else {
725        linear.feature_cols.clone()
726    };
727    let occurrences = numeric_cols.iter().filter(|&&c| c == deriv_col).count();
728    if occurrences == 0 {
729        return Ok(None);
730    }
731    let n = data.nrows();
732    let p = data.ncols();
733    for &c in &numeric_cols {
734        if c >= p {
735            return Err(BasisError::InvalidInput(format!(
736                "linear term '{}' feature column {c} out of bounds for {p} columns",
737                linear.name
738            )));
739        }
740    }
741
742    // gate(x): categorical-level indicators (constant w.r.t. a continuous axis).
743    let mut gate = Array1::<f64>::ones(n);
744    for &(col, level_bits) in &linear.categorical_levels {
745        if col >= p {
746            return Err(BasisError::InvalidInput(format!(
747                "linear term '{}' categorical column {col} out of bounds for {p} columns",
748                linear.name
749            )));
750        }
751        let level_bits = gam_data::canonical_level_bits(f64::from_bits(level_bits));
752        for (row, g) in gate.iter_mut().enumerate() {
753            if gam_data::canonical_level_bits(data[[row, col]]) != level_bits {
754                *g = 0.0;
755            }
756        }
757    }
758
759    // Product rule: sum over each occurrence of `deriv_col`, dropping that factor.
760    let mut derivative = Array1::<f64>::zeros(n);
761    for (j, &c_j) in numeric_cols.iter().enumerate() {
762        if c_j != deriv_col {
763            continue;
764        }
765        let mut term = gate.clone();
766        for (k, &c_k) in numeric_cols.iter().enumerate() {
767            if k != j {
768                term *= &data.column(c_k);
769            }
770        }
771        derivative += &term;
772    }
773    Ok(Some(derivative))
774}
775
776/// Analytic first-derivative design block for a single smooth term over
777/// `deriv_col`, aligned column-for-column with that term's value design block.
778///
779/// Only non-periodic 1-D B-splines are analytically supported. The block is
780/// `B'_raw(x) · M` where `B'_raw` is the raw B-spline basis FIRST DERIVATIVE on
781/// the term's frozen knots/degree and `M = metadata.identifiability_transform`
782/// is the same linear chart the value design applied (see
783/// `build_term_collection_derivative_design`).
784fn smooth_term_first_derivative_block(
785    data: ArrayView2<'_, f64>,
786    termspec: &SmoothTermSpec,
787    term_value: &SmoothTerm,
788    deriv_col: usize,
789) -> Result<(Array2<f64>, Option<Array1<f64>>), BasisError> {
790    let feature_col = match &termspec.basis {
791        SmoothBasisSpec::BSpline1D { feature_col, .. } => *feature_col,
792        other => {
793            return Err(BasisError::InvalidInput(format!(
794                "analytic average-derivative design only supports non-periodic 1-D B-spline \
795                 smooths over the differentiated covariate; term '{}' uses unsupported basis {}",
796                termspec.name,
797                smooth_basis_kind_label(other)
798            )));
799        }
800    };
801    if feature_col != deriv_col {
802        // The caller only dispatches here when `deriv_col` is one of the term's
803        // feature columns, and a `BSpline1D` term has exactly one. This guards
804        // the invariant rather than trusting it silently.
805        return Err(BasisError::InvalidInput(format!(
806            "analytic average-derivative design: B-spline term '{}' is over column {feature_col}, \
807             not the differentiated column {deriv_col}",
808            termspec.name
809        )));
810    }
811
812    let (knots, degree, transform, periodic, anchor_offset_coeffs) = match &term_value.metadata {
813        BasisMetadata::BSpline1D {
814            knots,
815            degree,
816            identifiability_transform,
817            periodic,
818            anchor_offset_coeffs,
819            ..
820        } => (
821            knots,
822            *degree,
823            identifiability_transform.as_ref(),
824            periodic,
825            anchor_offset_coeffs.as_ref(),
826        ),
827        other => {
828            return Err(BasisError::InvalidInput(format!(
829                "analytic average-derivative design expected B-spline metadata for term '{}', \
830                 found {other:?}",
831                termspec.name
832            )));
833        }
834    };
835    if periodic.is_some() {
836        return Err(BasisError::InvalidInput(format!(
837            "analytic average-derivative design does not support periodic/cyclic B-spline \
838             term '{}'",
839            termspec.name
840        )));
841    }
842    let degree = degree.ok_or_else(|| {
843        BasisError::InvalidInput(format!(
844            "B-spline term '{}' metadata is missing its effective degree",
845            termspec.name
846        ))
847    })?;
848
849    // Raw B-spline basis FIRST DERIVATIVE on the frozen knot geometry.
850    let (deriv_basis_arc, _) = crate::basis::create_basis::<crate::basis::Dense>(
851        data.column(deriv_col),
852        crate::basis::KnotSource::Provided(knots.view()),
853        degree,
854        crate::basis::BasisOptions::first_derivative(),
855    )?;
856    let deriv_basis = deriv_basis_arc.as_ref();
857
858    let affine_derivative = match anchor_offset_coeffs {
859        Some(beta_p) => {
860            if deriv_basis.ncols() != beta_p.len() {
861                return Err(BasisError::DimensionMismatch(format!(
862                    "B-spline term '{}': raw derivative basis has {} columns but the affine anchor lift has {} coefficients",
863                    termspec.name,
864                    deriv_basis.ncols(),
865                    beta_p.len()
866                )));
867            }
868            Some(deriv_basis.dot(beta_p))
869        }
870        None => None,
871    };
872
873    // Push the basis derivative through the SAME frozen linear chart the value
874    // design used. The fixed affine channel remains separate and is returned
875    // above, so neither channel is mistaken for a fitted coefficient.
876    let block = match transform {
877        Some(z) => {
878            if deriv_basis.ncols() != z.nrows() {
879                return Err(BasisError::DimensionMismatch(format!(
880                    "B-spline term '{}': raw derivative basis has {} columns but the frozen \
881                     identifiability transform has {} rows",
882                    termspec.name,
883                    deriv_basis.ncols(),
884                    z.nrows()
885                )));
886            }
887            gam_linalg::faer_ndarray::fast_ab(deriv_basis, z)
888        }
889        None => deriv_basis.to_owned(),
890    };
891    Ok((block, affine_derivative))
892}
893
894/// Short human-readable label for a smooth basis variant, used only in the
895/// unsupported-basis error of the analytic average-derivative design.
896fn smooth_basis_kind_label(basis: &SmoothBasisSpec) -> &'static str {
897    match basis {
898        SmoothBasisSpec::BSpline1D { .. } => "BSpline1D",
899        SmoothBasisSpec::TensorBSpline { .. } => "TensorBSpline",
900        SmoothBasisSpec::ByVariable { .. } => "ByVariable",
901        SmoothBasisSpec::FactorSumToZero { .. } => "FactorSumToZero",
902        SmoothBasisSpec::FactorSmooth { .. } => "FactorSmooth",
903        SmoothBasisSpec::BySmooth { .. } => "BySmooth",
904        SmoothBasisSpec::ThinPlate { .. } => "ThinPlate",
905        SmoothBasisSpec::Duchon { .. } => "Duchon",
906        SmoothBasisSpec::Matern { .. } => "Matern",
907        SmoothBasisSpec::Sphere { .. } => "Sphere",
908        SmoothBasisSpec::ConstantCurvature { .. } => "ConstantCurvature",
909        SmoothBasisSpec::MeasureJet { .. } => "MeasureJet",
910        SmoothBasisSpec::Pca { .. } => "Pca",
911    }
912}
913
914fn build_constraint_block(
915    n: usize,
916    parametric_block: Option<&Array2<f64>>,
917    owner_blocks: &[&DesignMatrix],
918) -> Result<Array2<f64>, BasisError> {
919    let param_cols = parametric_block.map_or(0, |mat| mat.ncols());
920    let owner_cols: usize = owner_blocks.iter().map(|design| design.ncols()).sum();
921    let mut block = Array2::<f64>::zeros((n, param_cols + owner_cols));
922    let mut col_start = 0usize;
923    if let Some(parametric) = parametric_block {
924        let col_end = col_start + parametric.ncols();
925        block
926            .slice_mut(s![.., col_start..col_end])
927            .assign(parametric);
928        col_start = col_end;
929    }
930    const CHUNK: usize = 1024;
931    for owner in owner_blocks {
932        let col_end = col_start + owner.ncols();
933        for row_start in (0..n).step_by(CHUNK) {
934            let row_end = (row_start + CHUNK).min(n);
935            let chunk = (*owner)
936                .try_row_chunk(row_start..row_end)
937                .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
938            block
939                .slice_mut(s![row_start..row_end, col_start..col_end])
940                .assign(&chunk);
941        }
942        col_start = col_end;
943    }
944    Ok(block)
945}
946
947fn design_cross_relative_residual(
948    lhs: &DesignMatrix,
949    rhs: &DesignMatrix,
950) -> Result<f64, BasisError> {
951    let n = lhs.nrows();
952    if rhs.nrows() != n {
953        return Err(BasisError::ConstraintMatrixRowMismatch {
954            basisrows: n,
955            constraintrows: rhs.nrows(),
956        });
957    }
958    const CHUNK: usize = 1024;
959    let mut cross = Array2::<f64>::zeros((lhs.ncols(), rhs.ncols()));
960    let mut lhs_sumsq = 0.0;
961    let mut rhs_sumsq = 0.0;
962    for start in (0..n).step_by(CHUNK) {
963        let end = (start + CHUNK).min(n);
964        let lhs_chunk = lhs
965            .try_row_chunk(start..end)
966            .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
967        let rhs_chunk = rhs
968            .try_row_chunk(start..end)
969            .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
970        cross += &gam_linalg::faer_ndarray::fast_atb(&lhs_chunk, &rhs_chunk);
971        lhs_sumsq += lhs_chunk.iter().map(|v| v * v).sum::<f64>();
972        rhs_sumsq += rhs_chunk.iter().map(|v| v * v).sum::<f64>();
973    }
974    let num = cross.iter().map(|v| v * v).sum::<f64>().sqrt();
975    let denom = (lhs_sumsq.sqrt() * rhs_sumsq.sqrt()).max(1e-300);
976    Ok(num / denom)
977}
978
979fn smooth_has_overlapping_linear_terms(
980    linear_terms: &[LinearTermSpec],
981    termspec: &SmoothTermSpec,
982) -> bool {
983    let feature_cols = smooth_term_feature_cols(termspec);
984    linear_terms
985        .iter()
986        .any(|linear| feature_cols.contains(&linear.feature_col))
987}
988
989// `pub` so the #1601-orphaned design-assembly regression guards (re-homed into
990// gam-models) can assert the intrinsic-parametric column resolution against this
991// exact production helper.
992pub fn smooth_intrinsic_parametric_feature_cols(
993    linear_terms: &[LinearTermSpec],
994    term: &SmoothTermSpec,
995) -> Vec<usize> {
996    // Returns the data columns that should appear in the smooth's parametric
997    // constraint block `C = [1, …]`, alongside which the smooth is then
998    // ORTHOGONALIZED via `apply_smooth_transform_to_design`.  Every column
999    // returned here is therefore a direction that gets projected OUT of the
1000    // smooth's basis.  The constant intercept is always included by
1001    // `build_parametric_constraint_block_for_term`, so this function only
1002    // controls the polynomial axes added on top of that intercept.
1003    //
1004    // Ownership rule: explicit linear terms claim their matching axes — and
1005    // only those axes — for projection.  A standalone smooth (no overlapping
1006    // linear term) keeps its full polynomial nullspace and is centered only
1007    // against the implicit intercept; this matches the canonical thin-plate
1008    // / Duchon model surface where the linear component is part of the
1009    // smooth itself.
1010    let feature_cols = smooth_term_feature_cols(term);
1011    let mut owned = Vec::new();
1012    for linear in linear_terms {
1013        if feature_cols.contains(&linear.feature_col) && !owned.contains(&linear.feature_col) {
1014            owned.push(linear.feature_col);
1015        }
1016    }
1017    owned
1018}
1019
1020fn apply_global_smooth_identifiability(
1021    smooth: RawSmoothDesign,
1022    data: ArrayView2<'_, f64>,
1023    linear_terms: &[LinearTermSpec],
1024    smoothspecs: &[SmoothTermSpec],
1025) -> Result<(SmoothDesign, Array1<f64>), BasisError> {
1026    // Global smooth identifiability policy:
1027    //
1028    // 1. Any smooth that overlaps explicit linear terms is residualized against
1029    //    [intercept | overlapping linear columns]. Spatial smooths also keep
1030    //    their existing parametric orthogonality policy when requested.
1031    // 2. Higher-order / duplicate smooths are orthogonalized to the realized
1032    //    design columns of lower-order owned smooths over nested feature sets.
1033    //
1034    // This yields a deterministic hierarchical decomposition: lower-order smooths
1035    // own their subspaces, and broader smooths fit only the residual structure.
1036    if smoothspecs.len() != smooth.terms.len() {
1037        gam_problem::bail_dim_basis!(
1038            "smooth spec count ({}) does not match built term count ({})",
1039            smoothspecs.len(),
1040            smooth.terms.len()
1041        );
1042    }
1043
1044    if smooth.terms.is_empty() {
1045        let RawSmoothDesign {
1046            term_designs,
1047            affine_offset,
1048            penalties,
1049            nullspace_dims,
1050            penaltyinfo,
1051            dropped_penaltyinfo,
1052            terms,
1053            coefficient_lower_bounds,
1054            linear_constraints,
1055        } = smooth;
1056        return Ok((
1057            SmoothDesign {
1058                term_designs,
1059                penalties,
1060                nullspace_dims,
1061                penaltyinfo,
1062                dropped_penaltyinfo,
1063                terms,
1064                coefficient_lower_bounds,
1065                linear_constraints,
1066            },
1067            affine_offset,
1068        ));
1069    }
1070
1071    let mut local_designs = vec![None; smooth.terms.len()];
1072    let mut local_active_penalties = vec![Vec::<ActivePenalty>::new(); smooth.terms.len()];
1073    let mut local_dropped_penalties = vec![Vec::<DroppedPenaltyInfo>::new(); smooth.terms.len()];
1074    let mut local_metadata = vec![None; smooth.terms.len()];
1075    let mut local_dims = vec![0usize; smooth.terms.len()];
1076    let mut local_linear_constraints = vec![None; smooth.terms.len()];
1077    let mut local_unabsorbed_z = vec![None::<Array2<f64>>; smooth.terms.len()];
1078
1079    let SmoothStructureAnalysis {
1080        ownership_order,
1081        term_owners,
1082        ..
1083    } = analyze_smooth_ownership(smoothspecs);
1084
1085    use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator};
1086
1087    for &idx in &ownership_order {
1088        let term = &smooth.terms[idx];
1089        let termspec = &smoothspecs[idx];
1090        let design_local = smooth.term_designs[idx].clone();
1091        // A frozen global-orthogonality chart (#978) is a pure replay: the
1092        // fit already decided this term's residualization against its owner
1093        // terms, and that decision is training-row data — rederiving it from
1094        // new rows would be wrong, and skipping it (the pre-#978 behavior)
1095        // emitted an unresidualized design wider than the fitted coefficient
1096        // block. So it bypasses both the owner analysis and the frozen-skip
1097        // gate below.
1098        let replay_z = frozen_global_orthogonality(termspec);
1099        let skip_global_transform = replay_z.is_none()
1100            && (smooth_has_frozen_identifiability(termspec) || term.lower_bounds_local.is_some());
1101        // A marginally-centered tensor interaction (`ti(...)`, MarginalSumToZero)
1102        // has ALREADY removed each axis's main effect analytically, in
1103        // coefficient space, via its per-margin sum-to-zero reparameterization
1104        // (B_xZ_x)⊗(B_zZ_z) — exactly mgcv's `ti` construction. Residualizing it
1105        // a SECOND time against the explicit s(x)/s(z) smooths' realized B-spline
1106        // column spans is redundant on an exact tensor grid (a no-op there) and
1107        // actively HARMFUL off-grid: the realized interaction columns share a
1108        // grid-dependent, jitter-sized projection with the main-effect bases, so
1109        // the second projection eats genuine pure-interaction curvature the main
1110        // effects cannot represent. REML then rails the s(x)/s(z) smoothing
1111        // parameters and the surface under-recovers (~40x, #1470). The analytic
1112        // marginal centering is the correct and complete main-effect removal, so
1113        // such a term takes NO owner block.
1114        let owner_indices = if replay_z.is_some()
1115            || skip_global_transform
1116            || termspec.basis.is_marginally_centered_tensor()
1117            || termspec.basis.is_sum_to_zero_factor_smooth()
1118        {
1119            Vec::new()
1120        } else {
1121            // Relative cross-residual above which a dependent smooth's design is
1122            // judged to share column space with an owner term and so needs that
1123            // owner's block in its identifiability transform.
1124            const OVERLAP_REL_RESIDUAL_TOL: f64 = 1e-10;
1125            let owner_cross_checks = term_owners[idx]
1126                .clone()
1127                .into_par_iter()
1128                .map(|owner_idx| {
1129                    let owner_design = local_designs[owner_idx]
1130                        .as_ref()
1131                        .expect("owner design must be available before dependent smooth");
1132                    design_cross_relative_residual(&design_local, owner_design)
1133                        .map(|rel| (owner_idx, rel))
1134                })
1135                .collect::<Vec<_>>();
1136            let mut out = Vec::new();
1137            for check in owner_cross_checks {
1138                let (owner_idx, rel) = check?;
1139                if rel > OVERLAP_REL_RESIDUAL_TOL {
1140                    out.push(owner_idx);
1141                }
1142            }
1143            out
1144        };
1145        let owner_blocks = owner_indices
1146            .iter()
1147            .map(|owner_idx| {
1148                local_designs[*owner_idx]
1149                    .as_ref()
1150                    .expect("owner design must be available before dependent smooth")
1151            })
1152            .collect::<Vec<_>>();
1153        let needs_parametric_block = replay_z.is_none()
1154            && !skip_global_transform
1155            && (smooth_has_overlapping_linear_terms(linear_terms, termspec)
1156                || !smooth_intrinsic_parametric_feature_cols(linear_terms, termspec).is_empty()
1157                || smooth_requires_parametric_orthogonality(termspec)
1158                // A factor-by-level smooth must always be centered against its
1159                // gated level indicator (see `factor_by_level_gate`) so its
1160                // within-level constant cannot collide with the treatment-coded
1161                // factor main effect — even when no continuous linear term
1162                // overlaps it (e.g. `s(x, by=fac)` with no `+ x`).
1163                || factor_by_level_gate(termspec).is_some());
1164        let parametric_block = if !needs_parametric_block {
1165            None
1166        } else {
1167            Some(build_parametric_constraint_block_for_term(
1168                data,
1169                linear_terms,
1170                termspec,
1171            )?)
1172        };
1173        let c_local =
1174            if skip_global_transform || (parametric_block.is_none() && owner_blocks.is_empty()) {
1175                None
1176            } else {
1177                Some(build_constraint_block(
1178                    data.nrows(),
1179                    parametric_block.as_ref(),
1180                    &owner_blocks,
1181                )?)
1182            };
1183        let z_opt = if let Some(z) = replay_z {
1184            if design_local.ncols() != z.nrows() {
1185                gam_problem::bail_dim_basis!(
1186                    "frozen global-orthogonality transform mismatch for term '{}': rebuilt design has {} columns but the persisted fit-time transform has {} rows",
1187                    term.name,
1188                    design_local.ncols(),
1189                    z.nrows()
1190                );
1191            }
1192            Some(z.clone())
1193        } else if skip_global_transform {
1194            None
1195        } else {
1196            match maybe_smooth_identifiability_transform(
1197                termspec,
1198                &design_local,
1199                c_local.as_ref().map(|mat| mat.view()),
1200            ) {
1201                Ok(z_opt) => z_opt,
1202                Err(BasisError::ConstraintNullspaceCollapsed { .. })
1203                    if !owner_blocks.is_empty() =>
1204                {
1205                    Some(Array2::zeros((design_local.ncols(), 0)))
1206                }
1207                Err(err) => return Err(err),
1208            }
1209        };
1210        let coefficient_gauge = z_opt
1211            .as_ref()
1212            .map(|z| gam_problem::Gauge::from_block_transforms(&[z.clone()]));
1213        let design_constrained = if let Some(gauge) = coefficient_gauge.as_ref() {
1214            apply_smooth_transform_to_design(design_local, &gauge.block_transform(0), &term.name)?
1215        } else {
1216            design_local
1217        };
1218
1219        if let Some(c_ref) = c_local.as_ref() {
1220            let rel =
1221                orthogonality_relative_residual_for_design(&design_constrained, c_ref.view())?;
1222            // Largest relative residual tolerated before the constrained design
1223            // is rejected as not orthogonal to its sum-to-zero constraint rows.
1224            const ORTHOGONALITY_REL_RESIDUAL_TOL: f64 = 1e-8;
1225            let tol = ORTHOGONALITY_REL_RESIDUAL_TOL;
1226            if rel > tol {
1227                gam_problem::bail_invalid_basis!(
1228                    "smooth orthogonality residual too large for term '{}': {:.3e} > {:.1e}",
1229                    term.name,
1230                    rel,
1231                    tol
1232                );
1233            }
1234        }
1235
1236        let penalty_candidates = term
1237            .active_penalties
1238            .par_iter()
1239            .map(|penalty| -> Result<PenaltyCandidate, BasisError> {
1240                let raw = ConstructiveQuadratic::try_from_dense_psd(
1241                    penalty.matrix.clone(),
1242                    "global smooth source penalty",
1243                )?;
1244                // Re-attach the structural null frame the basis factory
1245                // declared (#2445): `try_from_dense_psd` sees only the dense
1246                // matrix, and the declaration must survive this chokepoint so
1247                // the double-penalty rebuild below decides topology from the
1248                // carried theorem, not from a rank test on a matrix carrying
1249                // the Duchon conditioning ridge. `.restricted` transports it
1250                // through the global gauge.
1251                let raw = match penalty.info.structural_null_frame.as_ref() {
1252                    Some(frame) => raw.with_structural_null_frame(
1253                        frame.clone(),
1254                        "global smooth source penalty structural frame",
1255                    )?,
1256                    None => raw,
1257                };
1258                let restricted = if let Some(gauge) = coefficient_gauge.as_ref() {
1259                    raw.restricted(gauge, "global smooth identifiability restriction")?
1260                } else {
1261                    raw
1262                };
1263                let (_, c_new) = normalize_penalty_in_constrained_space(restricted.dense());
1264                let matrix = restricted.scaled(1.0 / c_new, "normalized global smooth penalty")?;
1265                Ok(PenaltyCandidate {
1266                    matrix,
1267                    source: penalty.info.source.clone(),
1268                    normalization_scale: penalty.info.normalization_scale * c_new,
1269                    kronecker_factors: None,
1270                    op: None,
1271                })
1272            })
1273            .collect::<Result<Vec<_>, _>>()?;
1274        // #1476-class fix (central, basis-agnostic): when a non-trivial GLOBAL
1275        // identifiability/orthogonalization transform `z_opt` was applied above,
1276        // it congruence-restricts EVERY penalty — including a Marra & Wood double-
1277        // penalty null-space shrinkage ridge (`DoublePenaltyNullspace`). A merely-
1278        // restricted ridge `Zᵀ (Z_null Z_nullᵀ) Z` is NOT the projector onto the
1279        // null space of the *constrained* bending penalty `Zᵀ S_bend Z`: the
1280        // sum-to-zero / parametric-orthogonalization `Z` is not norm-preserving and
1281        // typically DROPS the constant direction, so the restricted ridge is
1282        // neither idempotent nor aligned with `null(Zᵀ S_bend Z)` and shrinks
1283        // penalized directions (the #1266/#1476 flat-collapse / EDF mis-allocation
1284        // class). This is the single chokepoint every basis flows through, so
1285        // rebuild the ridge here from the null space of the constrained `Primary`
1286        // penalty, exactly as the 1-D B-spline / tensor / thin-plate paths do in
1287        // their own local builds. (Idempotent with those local rebuilds: when no
1288        // further `Primary`-null directions survive, the rebuilt ridge equals the
1289        // local one; when this global `Z` removes more, only this rebuild is
1290        // correct.) Scoped to `coefficient_gauge.is_some()`: with no global
1291        // transform the penalties are untouched and the basis-local ridge already
1292        // lives in the fit chart.
1293        let mut penalty_candidates = penalty_candidates;
1294        if coefficient_gauge.is_some()
1295            && penalty_candidates
1296                .iter()
1297                .any(|c| matches!(c.source, PenaltySource::DoublePenaltyNullspace))
1298        {
1299            // Nonzero-row support of a (symmetric) penalty matrix: the coefficient
1300            // range it actually penalizes. A per-level `by=factor` smooth emits one
1301            // `Primary`+`DoublePenaltyNullspace` pair PER LEVEL, each confined to
1302            // that level's disjoint `[off..off+p]` diagonal block (#1427), so a
1303            // ridge must be rebuilt from the Primary sharing ITS support — not the
1304            // first global Primary, and not the summed bending (which would collapse
1305            // the independent per-level λ). For a single smooth term there is one
1306            // Primary spanning the whole block and this reduces to the simple case.
1307            const SUPPORT_TOL: f64 = 0.0;
1308            let support_rows = |m: &Array2<f64>| -> (usize, usize) {
1309                let n = m.nrows();
1310                let mut lo = n;
1311                let mut hi = 0usize;
1312                for i in 0..n {
1313                    let any = (0..m.ncols()).any(|j| m[[i, j]].abs() > SUPPORT_TOL);
1314                    if any {
1315                        lo = lo.min(i);
1316                        hi = hi.max(i + 1);
1317                    }
1318                }
1319                (lo, hi)
1320            };
1321            // Snapshot each Primary's support + a clone of its matrix (immutable
1322            // borrow released before we mutate the ridges below).
1323            let primaries: Vec<((usize, usize), ConstructiveQuadratic)> = penalty_candidates
1324                .iter()
1325                .filter(|c| matches!(c.source, PenaltySource::Primary))
1326                .map(|c| -> Result<_, BasisError> {
1327                    Ok((
1328                        support_rows(&c.matrix),
1329                        c.matrix
1330                            .scaled(c.normalization_scale, "physical global smooth primary")?,
1331                    ))
1332                })
1333                .collect::<Result<Vec<_>, _>>()?;
1334            for candidate in &mut penalty_candidates {
1335                if !matches!(candidate.source, PenaltySource::DoublePenaltyNullspace) {
1336                    continue;
1337                }
1338                let q = candidate.matrix.nrows();
1339                let (rlo, rhi) = support_rows(&candidate.matrix);
1340                // The Primary whose support CONTAINS this ridge's support (the
1341                // co-located bending block). Falls back to the unique Primary when
1342                // the ridge is (numerically) empty.
1343                let owner = primaries
1344                    .iter()
1345                    .find(|((plo, phi), _)| *plo <= rlo && rhi <= *phi)
1346                    .or_else(|| (primaries.len() == 1).then(|| &primaries[0]))
1347                    .ok_or_else(|| {
1348                        BasisError::InvalidInput(format!(
1349                            "double-penalty ridge for smooth '{}' has no co-located primary penalty",
1350                            term.name
1351                        ))
1352                    })?;
1353                let ((plo, phi), s_full) = owner;
1354                // Rebuild from the physical Primary and ridge submatrices. Rank
1355                // revelation on the Primary's retained energy factor preserves
1356                // the structural null space through this global chart, while the
1357                // restricted ridge supplies the function-metric action. No signed
1358                // spectrum of a rounded dense congruence is classified (#2318).
1359                let block = ConstructiveQuadratic::from_energy_factor(
1360                    s_full.factor().slice(s![.., *plo..*phi]).to_owned(),
1361                    "owned global smooth primary block",
1362                )?;
1363                // The support-block extraction rebuilds the quadratic from a
1364                // sliced factor, so re-attach the declared structural frame
1365                // restricted to the same block (it is `None` when the frame
1366                // has support outside the block, and the rebuild then falls
1367                // back to measuring — never guesses).
1368                let block = match s_full.structural_null_frame_block(*plo, *phi) {
1369                    Some(frame) => block.with_structural_null_frame(
1370                        frame,
1371                        "owned global smooth primary block structural frame",
1372                    )?,
1373                    None => block,
1374                };
1375                let ridge_full = candidate.matrix.scaled(
1376                    candidate.normalization_scale,
1377                    "physical global smooth null ridge",
1378                )?;
1379                let ridge_block = ConstructiveQuadratic::from_energy_factor(
1380                    ridge_full.factor().slice(s![.., *plo..*phi]).to_owned(),
1381                    "owned global smooth null-ridge block",
1382                )?;
1383                let rebuilt_block =
1384                    crate::basis::rebuild_metric_consistent_ridge(&block, &ridge_block)?;
1385                match rebuilt_block {
1386                    Some(ridge_block) => {
1387                        let mut full_factor =
1388                            Array2::<f64>::zeros((ridge_block.factor().nrows(), q));
1389                        full_factor
1390                            .slice_mut(s![.., *plo..*phi])
1391                            .assign(ridge_block.factor());
1392                        let full = ConstructiveQuadratic::from_energy_factor(
1393                            full_factor,
1394                            "embedded global smooth null ridge",
1395                        )?;
1396                        let (_, scale) = normalize_penalty_in_constrained_space(full.dense());
1397                        candidate.matrix = full
1398                            .scaled(1.0 / scale, "normalized embedded global smooth null ridge")?;
1399                        candidate.normalization_scale = scale;
1400                        candidate.kronecker_factors = None;
1401                        candidate.op = None;
1402                    }
1403                    // Constrained bending block is full rank: no null space to
1404                    // shrink. Zero the ridge; the filter drops it.
1405                    None => {
1406                        candidate.matrix = ConstructiveQuadratic::zero(q);
1407                        candidate.normalization_scale = 1.0;
1408                        candidate.kronecker_factors = None;
1409                        candidate.op = None;
1410                    }
1411                }
1412            }
1413        }
1414        let filtered = filter_penalty_candidates(penalty_candidates)?;
1415        let linear_constraints_constrained =
1416            if let Some(lin_local) = term.linear_constraints_local.as_ref() {
1417                if let Some(gauge) = coefficient_gauge.as_ref() {
1418                    Some(LinearInequalityConstraints {
1419                        a: lin_local.a.dot(&gauge.block_transform(0)),
1420                        b: lin_local.b.clone(),
1421                    })
1422                } else {
1423                    Some(lin_local.clone())
1424                }
1425            } else {
1426                None
1427            };
1428
1429        local_dims[idx] = design_constrained.ncols();
1430        local_designs[idx] = Some(design_constrained);
1431        local_active_penalties[idx] = filtered.active;
1432        local_dropped_penalties[idx] = term.dropped_penalties.clone();
1433        local_dropped_penalties[idx].extend(filtered.dropped);
1434        local_linear_constraints[idx] = linear_constraints_constrained;
1435        let realized_transform = match (term.joint_null_rotation.as_ref(), z_opt.as_ref()) {
1436            (Some(rotation), Some(z)) => {
1437                Some(gam_linalg::faer_ndarray::fast_ab(&rotation.rotation, z))
1438            }
1439            (Some(rotation), None) => Some(rotation.rotation.clone()),
1440            (None, Some(z)) => Some(z.clone()),
1441            (None, None) => None,
1442        };
1443        // Factor-smooth kinds cannot absorb the realized transform into their
1444        // metadata, so it is exported on the term instead and persisted onto
1445        // the spec by `freeze_term_collection_from_design` (#978):
1446        //
1447        // - Block-replicated factor smooths (`bs="sz"` → `FactorSumToZero`)
1448        //   carry PER-MARGINAL metadata (predict rebuilds the single inner
1449        //   marginal then re-stacks the `L-1` sum-to-zero deviation blocks).
1450        //   The realized transform lives in the FULL `p·(L-1)`-column design
1451        //   space, so it cannot be folded into the per-marginal metadata (the
1452        //   dimensions don't compose; folding it in both crashed basis
1453        //   generation and would double-count `Q` on rebuild, #700). The raw
1454        //   design builder reapplies `Q` deterministically at predict time, so
1455        //   only the global-orthogonality `Z` (post-`Q` chart) is exported.
1456        //
1457        // - `FactorSmooth` (`fs`/`re`) metadata has no transform slot at all
1458        //   (its `with_identifiability_transform` arm rejects one). Like `sz`,
1459        //   any stage-2 joint-null `Q` is recomputed by the raw builder on
1460        //   rebuild (and is typically absent: `fs` penalties are full-rank),
1461        //   so the exported chart is likewise the post-`Q` `Z` alone.
1462        //
1463        // Without this export the overlap residualization of
1464        // `s(x) + s(g, x, bs=sz)` / `s(x) + fs(x, g)` was silently dropped:
1465        // the fit used the narrowed `X·Z` design while every predict rebuilt
1466        // the full-width design, making the model unpredictable (#978).
1467        match &termspec.basis {
1468            SmoothBasisSpec::FactorSumToZero { .. } | SmoothBasisSpec::FactorSmooth { .. } => {
1469                local_metadata[idx] = Some(term.metadata.clone());
1470                local_unabsorbed_z[idx] = z_opt.clone();
1471            }
1472            _ => {
1473                local_metadata[idx] = Some(with_identifiability_transform(
1474                    &term.metadata,
1475                    realized_transform.as_ref(),
1476                )?);
1477            }
1478        }
1479    }
1480
1481    let total_p: usize = local_dims.iter().sum();
1482    let mut terms_out = Vec::<SmoothTerm>::with_capacity(smooth.terms.len());
1483    let mut penalties_global = Vec::<BlockwisePenalty>::new();
1484    let mut nullspace_dims_global = Vec::<usize>::new();
1485    let mut penaltyinfo_global = Vec::<PenaltyBlockInfo>::new();
1486    let mut dropped_penaltyinfo_global = Vec::<DroppedPenaltyBlockInfo>::new();
1487    let mut coefficient_lower_bounds = Array1::<f64>::from_elem(total_p, f64::NEG_INFINITY);
1488    let mut any_bounds = false;
1489    let mut linear_constraintsrows: Vec<Array1<f64>> = Vec::new();
1490    let mut linear_constraints_b: Vec<f64> = Vec::new();
1491
1492    let mut col_start = 0usize;
1493    for idx in 0..smooth.terms.len() {
1494        let p_local = local_dims[idx];
1495        let col_end = col_start + p_local;
1496
1497        for active_penalty in &local_active_penalties[idx] {
1498            let global_index = penalties_global.len();
1499            penalties_global.push(BlockwisePenalty::new(
1500                col_start..col_end,
1501                active_penalty.matrix.clone(),
1502            ));
1503            nullspace_dims_global.push(active_penalty.nullity);
1504            penaltyinfo_global.push(PenaltyBlockInfo {
1505                global_index,
1506                termname: Some(smooth.terms[idx].name.clone()),
1507                penalty: active_penalty.info.clone(),
1508            });
1509        }
1510        for info in &local_dropped_penalties[idx] {
1511            dropped_penaltyinfo_global.push(DroppedPenaltyBlockInfo {
1512                termname: Some(smooth.terms[idx].name.clone()),
1513                penalty: info.clone(),
1514            });
1515        }
1516
1517        terms_out.push(SmoothTerm {
1518            name: smooth.terms[idx].name.clone(),
1519            coeff_range: col_start..col_end,
1520            shape: smooth.terms[idx].shape,
1521            active_penalties: local_active_penalties[idx].clone(),
1522            dropped_penalties: local_dropped_penalties[idx].clone(),
1523            metadata: local_metadata[idx]
1524                .clone()
1525                .expect("local metadata must exist for every smooth term"),
1526            lower_bounds_local: smooth.terms[idx].lower_bounds_local.clone(),
1527            linear_constraints_local: local_linear_constraints[idx].clone(),
1528            // Global orthogonality transforms break Kronecker structure.
1529            kronecker_factored: None,
1530            // The final raw-basis → coefficient chart, including any
1531            // stage-2 joint-null Q and global orthogonality Z, is embedded in
1532            // `metadata` above. Keeping Q separately here would apply it twice
1533            // on frozen rebuilds and would put derivative operators in a
1534            // different chart from the value path.
1535            joint_null_rotation: None,
1536            // Factor-smooth kinds export the chart their metadata could not
1537            // absorb; the freeze persists it onto the spec for replay (#978).
1538            unabsorbed_global_orthogonality: local_unabsorbed_z[idx].clone(),
1539        });
1540        if let Some(lin_local) = &local_linear_constraints[idx] {
1541            for r in 0..lin_local.a.nrows() {
1542                let mut row = Array1::<f64>::zeros(total_p);
1543                row.slice_mut(s![col_start..col_end])
1544                    .assign(&lin_local.a.row(r));
1545                linear_constraintsrows.push(row);
1546                linear_constraints_b.push(lin_local.b[r]);
1547            }
1548        }
1549        if let Some(lb_local) = smooth.terms[idx].lower_bounds_local.as_ref()
1550            && lb_local.len() == p_local
1551        {
1552            coefficient_lower_bounds
1553                .slice_mut(s![col_start..col_end])
1554                .assign(lb_local);
1555            any_bounds = true;
1556        }
1557
1558        col_start = col_end;
1559    }
1560
1561    assert_eq!(
1562        penalties_global.len(),
1563        nullspace_dims_global.len(),
1564        "globally reparameterized smooth penalty/nullspace bookkeeping diverged"
1565    );
1566    assert_eq!(
1567        penalties_global.len(),
1568        penaltyinfo_global.len(),
1569        "globally reparameterized smooth penalty metadata bookkeeping diverged"
1570    );
1571
1572    Ok((
1573        SmoothDesign {
1574            term_designs: local_designs
1575                .into_iter()
1576                .map(|design| design.expect("local design must exist for every smooth term"))
1577                .collect(),
1578            penalties: penalties_global,
1579            nullspace_dims: nullspace_dims_global,
1580            penaltyinfo: penaltyinfo_global,
1581            dropped_penaltyinfo: dropped_penaltyinfo_global,
1582            terms: terms_out,
1583            coefficient_lower_bounds: if any_bounds {
1584                Some(coefficient_lower_bounds)
1585            } else {
1586                None
1587            },
1588            linear_constraints: if linear_constraintsrows.is_empty() {
1589                None
1590            } else {
1591                let mut a = Array2::<f64>::zeros((linear_constraintsrows.len(), total_p));
1592                for (i, row) in linear_constraintsrows.iter().enumerate() {
1593                    a.row_mut(i).assign(row);
1594                }
1595                Some(LinearInequalityConstraints {
1596                    a,
1597                    b: Array1::from_vec(linear_constraints_b),
1598                })
1599            },
1600        },
1601        smooth.affine_offset,
1602    ))
1603}
1604
1605/// If `termspec` is a single-level factor-by smooth (`s(x, by=fac)` expanded
1606/// into one `ByVariable { kind: Level }` block per factor level), return the
1607/// `(by_col, value_bits)` pair identifying which rows that level's block gates
1608/// to. `None` for numeric-by smooths and every other basis.
1609///
1610/// A factor-by smooth's per-level block is the inner basis multiplied by the
1611/// level indicator (zero on every other level's rows). Its column span
1612/// therefore contains the per-level CONSTANT — a vector that is `1` on this
1613/// level's rows and `0` elsewhere — which is exactly the column the
1614/// treatment-coded factor main effect (`build_termspec` auto-adds one as an
1615/// unpenalized random-effect term) already carries. Centering each level's
1616/// smooth against the *global* intercept (`build_parametric_constraint_block_for_term`'s
1617/// default) removes only its global mean, leaving that within-level constant
1618/// to collide with the factor main effect: a rank-1 collinearity that lets the
1619/// penalty/ridge split the per-group baseline level between the two blocks and
1620/// under-recover it (the per-group log-cumulative-hazard offset leaks out — the
1621/// #900 weibull-AFT-by-factor surface miscalibration). Centering against the
1622/// gated level indicator instead removes the within-level constant cleanly,
1623/// leaving the per-group level entirely to the factor main effect (mgcv's
1624/// by-factor convention), while the per-level slope/curvature deviation stays
1625/// in the smooth (we deliberately do NOT project the overlapping continuous
1626/// axis out of a by-level smooth — that deviation is the by-factor signal).
1627fn factor_by_level_gate(termspec: &SmoothTermSpec) -> Option<(usize, u64)> {
1628    match &termspec.basis {
1629        SmoothBasisSpec::ByVariable {
1630            by_col,
1631            by: ByVariableSpec::Level { value_bits, .. },
1632            ..
1633        } => Some((*by_col, *value_bits)),
1634        _ => None,
1635    }
1636}
1637
1638fn build_parametric_constraint_block_for_term(
1639    data: ArrayView2<'_, f64>,
1640    linear_terms: &[LinearTermSpec],
1641    termspec: &SmoothTermSpec,
1642) -> Result<Array2<f64>, BasisError> {
1643    let n = data.nrows();
1644    let p_data = data.ncols();
1645
1646    // Factor-by-level smooth: center against the gated level indicator so the
1647    // within-level constant is removed (it belongs to the treatment-coded
1648    // factor main effect), not against the global `[1 | overlapping axes]`.
1649    if let Some((by_col, value_bits)) = factor_by_level_gate(termspec) {
1650        if by_col >= p_data {
1651            gam_problem::bail_dim_basis!(
1652                "factor-by smooth term '{}' by column {by_col} out of bounds for {p_data} columns",
1653                termspec.name
1654            );
1655        }
1656        let mut c = Array2::<f64>::zeros((n, 1));
1657        let by = data.column(by_col);
1658        let value_bits = gam_data::canonical_level_bits(f64::from_bits(value_bits));
1659        for (row, &value) in by.iter().enumerate() {
1660            if gam_data::canonical_level_bits(value) == value_bits {
1661                c[[row, 0]] = 1.0;
1662            }
1663        }
1664        return Ok(c);
1665    }
1666
1667    let feature_cols = smooth_term_feature_cols(termspec);
1668    let mut parametric_cols = smooth_intrinsic_parametric_feature_cols(linear_terms, termspec);
1669    for &feature_col in &parametric_cols {
1670        if feature_col >= p_data {
1671            gam_problem::bail_dim_basis!(
1672                "smooth term feature column {feature_col} out of bounds for {p_data} columns"
1673            );
1674        }
1675    }
1676    for linear in linear_terms
1677        .iter()
1678        .filter(|linear| feature_cols.contains(&linear.feature_col))
1679    {
1680        if linear.feature_col >= p_data {
1681            gam_problem::bail_dim_basis!(
1682                "linear term '{}' feature column {} out of bounds for {} columns",
1683                linear.name,
1684                linear.feature_col,
1685                p_data
1686            );
1687        }
1688        if !parametric_cols.contains(&linear.feature_col) {
1689            parametric_cols.push(linear.feature_col);
1690        }
1691    }
1692
1693    let mut c = Array2::<f64>::zeros((n, 1 + parametric_cols.len()));
1694    c.column_mut(0).fill(1.0);
1695    for (j, &feature_col) in parametric_cols.iter().enumerate() {
1696        c.column_mut(j + 1).assign(&data.column(feature_col));
1697    }
1698    Ok(c)
1699}
1700
1701pub fn apply_smooth_transform_to_design(
1702    design_local: DesignMatrix,
1703    transform: &Array2<f64>,
1704    termname: &str,
1705) -> Result<DesignMatrix, BasisError> {
1706    match design_local {
1707        DesignMatrix::Dense(inner) => {
1708            let op = CoefficientTransformOperator::new(inner, transform.clone()).map_err(|e| {
1709                BasisError::InvalidInput(format!(
1710                    "smooth identifiability transform failed for term '{termname}': {e}"
1711                ))
1712            })?;
1713            Ok(DesignMatrix::Dense(
1714                gam_linalg::matrix::DenseDesignMatrix::from(Arc::new(op)),
1715            ))
1716        }
1717        DesignMatrix::Sparse(inner) => {
1718            let dense = inner
1719                .try_to_dense_arc("smooth identifiability sparse transform")
1720                .map_err(BasisError::InvalidInput)?
1721                .as_ref()
1722                .dot(transform);
1723            Ok(DesignMatrix::Dense(
1724                gam_linalg::matrix::DenseDesignMatrix::from(dense),
1725            ))
1726        }
1727    }
1728}
1729
1730fn design_constraint_cross(
1731    design: &DesignMatrix,
1732    constraint_matrix: ArrayView2<'_, f64>,
1733) -> Result<Array2<f64>, BasisError> {
1734    let n = design.nrows();
1735    if constraint_matrix.nrows() != n {
1736        return Err(BasisError::ConstraintMatrixRowMismatch {
1737            basisrows: n,
1738            constraintrows: constraint_matrix.nrows(),
1739        });
1740    }
1741    let mut cross = Array2::<f64>::zeros((design.ncols(), constraint_matrix.ncols()));
1742    const CHUNK: usize = 1024;
1743    for start in (0..n).step_by(CHUNK) {
1744        let end = (start + CHUNK).min(n);
1745        let design_chunk = design
1746            .try_row_chunk(start..end)
1747            .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
1748        let constraint_chunk = constraint_matrix.slice(s![start..end, ..]).to_owned();
1749        cross += &gam_linalg::faer_ndarray::fast_atb(&design_chunk, &constraint_chunk);
1750    }
1751    Ok(cross)
1752}
1753
1754fn design_frobenius_norm(design: &DesignMatrix) -> Result<f64, BasisError> {
1755    let n = design.nrows();
1756    const CHUNK: usize = 1024;
1757    let mut sumsq = 0.0;
1758    for start in (0..n).step_by(CHUNK) {
1759        let end = (start + CHUNK).min(n);
1760        let chunk = design
1761            .try_row_chunk(start..end)
1762            .map_err(|e| BasisError::InvalidInput(e.to_string()))?;
1763        sumsq += chunk.iter().map(|v| v * v).sum::<f64>();
1764    }
1765    Ok(sumsq.sqrt())
1766}
1767
1768/// The persisted fit-time global-orthogonality chart for a factor-smooth
1769/// term, if one was frozen onto its spec (#978). `Some` means this term was
1770/// residualized against owner terms at fit time and prediction/refit rebuilds
1771/// must replay exactly that column map instead of rederiving anything from
1772/// the (new) rows.
1773fn frozen_global_orthogonality(termspec: &SmoothTermSpec) -> Option<&Array2<f64>> {
1774    match &termspec.basis {
1775        SmoothBasisSpec::FactorSumToZero {
1776            frozen_global_orthogonality,
1777            ..
1778        } => frozen_global_orthogonality.as_ref(),
1779        SmoothBasisSpec::FactorSmooth { spec } => spec.frozen_global_orthogonality.as_ref(),
1780        _ => None,
1781    }
1782}
1783
1784fn maybe_smooth_identifiability_transform(
1785    termspec: &SmoothTermSpec,
1786    design_local: &DesignMatrix,
1787    constraint_block: Option<ArrayView2<'_, f64>>,
1788) -> Result<Option<Array2<f64>>, BasisError> {
1789    if let Some(SpatialIdentifiability::FrozenTransform { transform }) =
1790        spatial_identifiability_policy(termspec)
1791    {
1792        if design_local.ncols() != transform.nrows() {
1793            gam_problem::bail_dim_basis!(
1794                "frozen spatial identifiability transform mismatch: design has {} columns but transform has {} rows",
1795                design_local.ncols(),
1796                transform.nrows()
1797            );
1798        }
1799        return Ok(Some(transform.clone()));
1800    }
1801
1802    if let Some(c) = constraint_block {
1803        if c.ncols() == 0 {
1804            Ok(None)
1805        } else {
1806            Ok(Some(orthogonality_transform_for_design(
1807                design_local,
1808                c,
1809                None, // fixed subspace: do not use iteration-varying PIRLS weights
1810            )?))
1811        }
1812    } else {
1813        Ok(None)
1814    }
1815}
1816
1817/// Whether this smooth's *realized* design (the basis evaluated at the n data
1818/// rows) must be residualized against the model's parametric block (intercept +
1819/// any overlapping linear columns) by `apply_global_smooth_identifiability`.
1820///
1821/// This is the universal identifiability invariant for **kernel / radial**
1822/// spatial smooths (#531): their realized column span contains the constant
1823/// (and, at `Linear` null-space order, the linear monomials), so without this
1824/// step the smooth and the parametric intercept fight over the same direction —
1825/// a structural rank-1 collision. The collision is invisible to the kernels'
1826/// *own* identifiability constraints because those act in **coefficient space at
1827/// the K centers**, not on the realized design rows:
1828///   - Matérn `CenterSumToZero` enforces `1ᵀα = 0` over the centers, so
1829///     `Kα` evaluated at the data rows still spans the constant.
1830///   - Duchon / TPS `OrthogonalToParametric` *defers* its centering to this very
1831///     step, which is why it is listed here too.
1832///
1833/// Tensor-product and B-spline bases instead apply a realized-design sum-to-zero
1834/// at basis-build time (`apply_sum_to_zero_constraint`), so they already satisfy
1835/// the invariant and must NOT be double-constrained — they return `false`.
1836///
1837/// The remaining bases are excluded, each for a concrete reason:
1838///   - **Sphere, Harmonic method**: the real-spherical-harmonic basis starts at
1839///     degree `l = 1` (`build_spherical_harmonic_basis`), so it never spans the
1840///     degree-0 constant — no centering is needed.
1841///   - **Sphere, Wahba method**: INCLUDED (#532). Its raw finite-center kernel
1842///     chart can span a near-constant realized direction even though the
1843///     continuous kernel omits the l=0 mode — same collision class as Matérn
1844///     `CenterSumToZero`. The composed parametric transform is frozen
1845///     onto `SphericalSplineBasisSpec::identifiability`
1846///     (`SphericalSplineIdentifiability::FrozenTransform`) and replayed by
1847///     `build_spherical_spline_basis` at predict time, so the orthogonalization
1848///     survives save → reload exactly as it does for Matérn.
1849///   - **PCA**: its `with_identifiability_transform` arm rejects a post-hoc
1850///     transform (the constraint lives inside the orthonormal loadings), and its
1851///     constant content is governed by the `centered` flag, not a residualizable
1852///     design.
1853///
1854/// `FrozenTransform` bases are excluded: a transform frozen by *this* pipeline
1855/// already has the parametric orthogonalization composed in (see
1856/// `with_identifiability_transform`), and they are gated out upstream by
1857/// `skip_global_transform` regardless.
1858fn smooth_requires_parametric_orthogonality(termspec: &SmoothTermSpec) -> bool {
1859    match &termspec.basis {
1860        SmoothBasisSpec::ByVariable { inner, .. }
1861        | SmoothBasisSpec::FactorSumToZero { inner, .. } => {
1862            smooth_requires_parametric_orthogonality(&SmoothTermSpec {
1863                name: termspec.name.clone(),
1864                basis: (**inner).clone(),
1865                shape: termspec.shape,
1866                joint_null_rotation: None,
1867            })
1868        }
1869        SmoothBasisSpec::BySmooth { smooth, .. } => {
1870            smooth_requires_parametric_orthogonality(&SmoothTermSpec {
1871                name: termspec.name.clone(),
1872                basis: (**smooth).clone(),
1873                shape: termspec.shape,
1874                joint_null_rotation: None,
1875            })
1876        }
1877        SmoothBasisSpec::ThinPlate { spec, .. } => {
1878            matches!(
1879                spec.identifiability,
1880                SpatialIdentifiability::OrthogonalToParametric
1881            )
1882        }
1883        SmoothBasisSpec::Duchon { spec, .. } => {
1884            matches!(
1885                spec.identifiability,
1886                SpatialIdentifiability::OrthogonalToParametric
1887            )
1888        }
1889        SmoothBasisSpec::Matern { spec, .. } => matches!(
1890            spec.identifiability,
1891            MaternIdentifiability::CenterSumToZero | MaternIdentifiability::CenterLinearOrthogonal
1892        ),
1893        // Wahba sphere (`bs="sos"`, method=Wahba): the finite-center Sobolev
1894        // kernel chart can still span a near-constant realized direction on
1895        // the data rows, so it requires global parametric orthogonalization
1896        // (#532). Pseudo is resolved by the basis builder to the harmonic
1897        // engine, whose degree l=1 start never spans the constant; forcing it
1898        // through this post-build transform would also renormalize away the
1899        // harmonic engine's physical spectral penalty scale.
1900        SmoothBasisSpec::Sphere { spec, .. } => {
1901            matches!(spec.method, crate::basis::SphereMethod::Wahba)
1902                && !matches!(spec.wahba_kernel, crate::basis::SphereWahbaKernel::Pseudo)
1903                && matches!(
1904                    spec.identifiability,
1905                    SphericalSplineIdentifiability::CenterSumToZero
1906                )
1907        }
1908        // Constant-curvature geodesic kernel: same #531 collision class as the
1909        // raw finite-center Wahba sphere. Its coefficient-space sum-to-zero `z`
1910        // leaves the realized `K·z` design spanning the constant on the data
1911        // rows, so the global parametric orthogonalization must compose onto
1912        // `z` (#532).
1913        SmoothBasisSpec::ConstantCurvature { spec, .. } => matches!(
1914            spec.identifiability,
1915            ConstantCurvatureIdentifiability::CenterSumToZero
1916        ),
1917        // Measure-jet representer: identical #531 collision class to the raw
1918        // finite-center Wahba sphere. Gaussian RBF columns times the
1919        // center-space sum-to-zero `z` still span the constant on the data rows,
1920        // so `z` must absorb the parametric orthogonalization (#532).
1921        SmoothBasisSpec::MeasureJet { spec, .. } => matches!(
1922            spec.identifiability,
1923            MeasureJetIdentifiability::CenterSumToZero
1924        ),
1925        SmoothBasisSpec::BSpline1D { .. }
1926        | SmoothBasisSpec::TensorBSpline { .. }
1927        | SmoothBasisSpec::Pca { .. }
1928        | SmoothBasisSpec::FactorSmooth { .. } => false,
1929    }
1930}
1931
1932fn compose_identifiability_transforms(
1933    existing: Option<&Array2<f64>>,
1934    extra: Option<&Array2<f64>>,
1935) -> Result<Option<Array2<f64>>, BasisError> {
1936    match (existing, extra) {
1937        (Some(lhs), Some(rhs)) => {
1938            if lhs.ncols() == rhs.nrows() {
1939                Ok(Some(lhs.dot(rhs)))
1940            } else if lhs.nrows() == rhs.nrows() && lhs.ncols() == rhs.ncols() {
1941                // Rebuilding from an already-frozen spec can surface the same
1942                // raw->frozen transform twice. Treat that as idempotent
1943                // metadata, not a sequential Z_left * Z_right composition.
1944                Ok(Some(rhs.clone()))
1945            } else {
1946                Err(BasisError::DimensionMismatch(format!(
1947                    "identifiability transform mismatch: existing is {}x{}, extra is {}x{}",
1948                    lhs.nrows(),
1949                    lhs.ncols(),
1950                    rhs.nrows(),
1951                    rhs.ncols(),
1952                )))
1953            }
1954        }
1955        (Some(lhs), None) => Ok(Some(lhs.clone())),
1956        (None, Some(rhs)) => Ok(Some(rhs.clone())),
1957        (None, None) => Ok(None),
1958    }
1959}
1960
1961fn with_identifiability_transform(
1962    metadata: &BasisMetadata,
1963    transform: Option<&Array2<f64>>,
1964) -> Result<BasisMetadata, BasisError> {
1965    match metadata {
1966        BasisMetadata::BSpline1D {
1967            knots,
1968            identifiability_transform,
1969            periodic,
1970            degree,
1971            auto_shrink_note,
1972            anchor_offset_coeffs,
1973        } => Ok(BasisMetadata::BSpline1D {
1974            knots: knots.clone(),
1975            periodic: *periodic,
1976            identifiability_transform: compose_identifiability_transforms(
1977                identifiability_transform.as_ref(),
1978                transform,
1979            )?,
1980            degree: *degree,
1981            auto_shrink_note: auto_shrink_note.clone(),
1982            // The offset coefficients live in the raw-basis chart and are
1983            // unaffected by an added constrained-chart identifiability
1984            // transform; carry them through unchanged (#2297).
1985            anchor_offset_coeffs: anchor_offset_coeffs.clone(),
1986        }),
1987        BasisMetadata::CubicRegression1D {
1988            knots,
1989            identifiability_transform,
1990        } => Ok(BasisMetadata::CubicRegression1D {
1991            knots: knots.clone(),
1992            identifiability_transform: compose_identifiability_transforms(
1993                identifiability_transform.as_ref(),
1994                transform,
1995            )?,
1996        }),
1997        BasisMetadata::ThinPlate {
1998            centers,
1999            length_scale,
2000            periodic,
2001            identifiability_transform,
2002            input_scale,
2003            radial_reparam,
2004        } => Ok(BasisMetadata::ThinPlate {
2005            centers: centers.clone(),
2006            length_scale: *length_scale,
2007            periodic: periodic.clone(),
2008            identifiability_transform: compose_identifiability_transforms(
2009                identifiability_transform.as_ref(),
2010                transform,
2011            )?,
2012            input_scale: *input_scale,
2013            radial_reparam: radial_reparam.clone(),
2014        }),
2015        BasisMetadata::Sphere {
2016            centers,
2017            penalty_order,
2018            method,
2019            max_degree,
2020            wahba_kernel,
2021            constraint_transform,
2022        } => Ok(BasisMetadata::Sphere {
2023            centers: centers.clone(),
2024            penalty_order: *penalty_order,
2025            method: *method,
2026            max_degree: *max_degree,
2027            wahba_kernel: *wahba_kernel,
2028            constraint_transform: compose_identifiability_transforms(
2029                constraint_transform.as_ref(),
2030                transform,
2031            )?,
2032        }),
2033        BasisMetadata::ConstantCurvature {
2034            centers,
2035            kappa,
2036            length_scale,
2037            constraint_transform,
2038        } => Ok(BasisMetadata::ConstantCurvature {
2039            centers: centers.clone(),
2040            kappa: *kappa,
2041            length_scale: *length_scale,
2042            constraint_transform: compose_identifiability_transforms(
2043                constraint_transform.as_ref(),
2044                transform,
2045            )?,
2046        }),
2047        BasisMetadata::MeasureJet {
2048            centers,
2049            input_scale,
2050            length_scale,
2051            eps_band,
2052            order_s,
2053            alpha,
2054            tau0,
2055            masses,
2056            support_means,
2057            penalty_normalization_scales,
2058            raw_penalty_normalization_scales,
2059            fused_penalty_normalization_scale,
2060            constraint_transform,
2061            sigma_coord,
2062        } => Ok(BasisMetadata::MeasureJet {
2063            centers: centers.clone(),
2064            input_scale: *input_scale,
2065            length_scale: *length_scale,
2066            eps_band: eps_band.clone(),
2067            order_s: *order_s,
2068            alpha: *alpha,
2069            tau0: *tau0,
2070            masses: masses.clone(),
2071            support_means: support_means.clone(),
2072            penalty_normalization_scales: penalty_normalization_scales.clone(),
2073            raw_penalty_normalization_scales: raw_penalty_normalization_scales.clone(),
2074            fused_penalty_normalization_scale: *fused_penalty_normalization_scale,
2075            constraint_transform: compose_identifiability_transforms(
2076                constraint_transform.as_ref(),
2077                transform,
2078            )?,
2079            sigma_coord: *sigma_coord,
2080        }),
2081        BasisMetadata::Matern {
2082            centers,
2083            length_scale,
2084            periodic,
2085            nu,
2086            include_intercept,
2087            identifiability_transform,
2088            input_scale,
2089            aniso_log_scales,
2090        } => Ok(BasisMetadata::Matern {
2091            centers: centers.clone(),
2092            length_scale: *length_scale,
2093            periodic: periodic.clone(),
2094            nu: *nu,
2095            include_intercept: *include_intercept,
2096            identifiability_transform: compose_identifiability_transforms(
2097                identifiability_transform.as_ref(),
2098                transform,
2099            )?,
2100            input_scale: *input_scale,
2101            aniso_log_scales: aniso_log_scales.clone(),
2102        }),
2103        BasisMetadata::Duchon {
2104            centers,
2105            length_scale,
2106            periodic,
2107            power,
2108            nullspace_order,
2109            identifiability_transform,
2110            input_scale,
2111            aniso_log_scales,
2112            operator_collocation_points,
2113            radial_reparam,
2114        } => Ok(BasisMetadata::Duchon {
2115            centers: centers.clone(),
2116            length_scale: *length_scale,
2117            periodic: periodic.clone(),
2118            power: *power,
2119            nullspace_order: *nullspace_order,
2120            input_scale: *input_scale,
2121            aniso_log_scales: aniso_log_scales.clone(),
2122            operator_collocation_points: operator_collocation_points.clone(),
2123            radial_reparam: radial_reparam.clone(),
2124            identifiability_transform: compose_identifiability_transforms(
2125                identifiability_transform.as_ref(),
2126                transform,
2127            )?,
2128        }),
2129        BasisMetadata::SphereHarmonics {
2130            max_degree,
2131            radians,
2132        } => Ok(BasisMetadata::SphereHarmonics {
2133            max_degree: *max_degree,
2134            radians: *radians,
2135        }),
2136        BasisMetadata::TensorBSpline {
2137            feature_cols,
2138            knots,
2139            degrees,
2140            periods,
2141            is_cr,
2142            identifiability_transform,
2143        } => Ok(BasisMetadata::TensorBSpline {
2144            feature_cols: feature_cols.clone(),
2145            knots: knots.clone(),
2146            degrees: degrees.clone(),
2147            periods: periods.clone(),
2148            is_cr: is_cr.clone(),
2149            identifiability_transform: compose_identifiability_transforms(
2150                identifiability_transform.as_ref(),
2151                transform,
2152            )?,
2153        }),
2154        BasisMetadata::BySmooth {
2155            inner,
2156            by_col,
2157            levels,
2158            ordered,
2159        } => Ok(BasisMetadata::BySmooth {
2160            inner: Box::new(with_identifiability_transform(inner, transform)?),
2161            by_col: *by_col,
2162            levels: levels.clone(),
2163            ordered: *ordered,
2164        }),
2165        BasisMetadata::FactorSmooth {
2166            continuous_cols,
2167            group_col,
2168            knots,
2169            degree,
2170            periodic,
2171            group_levels,
2172            flavour,
2173            marginal_is_cr,
2174        } => {
2175            // Factor-smooth metadata has no transform slot; the global pass
2176            // exports its transform via `SmoothTerm::unabsorbed_global_orthogonality`
2177            // instead (#978). Silently dropping a transform here is what made
2178            // `s(x) + fs(x, g)` unpredictable — reject loudly so any future
2179            // caller that reaches this arm with a transform fails at fit time
2180            // rather than corrupting the saved coefficient chart.
2181            if transform.is_some() {
2182                gam_problem::bail_invalid_basis!(
2183                    "FactorSmooth metadata cannot absorb an identifiability transform; \
2184                     route it through the term-level frozen_global_orthogonality carrier"
2185                );
2186            }
2187            Ok(BasisMetadata::FactorSmooth {
2188                continuous_cols: continuous_cols.clone(),
2189                group_col: *group_col,
2190                knots: knots.clone(),
2191                degree: *degree,
2192                periodic: *periodic,
2193                group_levels: group_levels.clone(),
2194                flavour: flavour.clone(),
2195                marginal_is_cr: *marginal_is_cr,
2196            })
2197        }
2198        BasisMetadata::Pca {
2199            feature_cols,
2200            basis_matrix,
2201            centered,
2202            smooth_penalty,
2203            center_mean,
2204            pca_basis_path,
2205            chunk_size,
2206        } => {
2207            // PCA bases carry an orthonormal projection matrix and do not
2208            // expose an identifiability transform that can be re-composed
2209            // (the constraint, if any, lives inside the PCA loadings
2210            // themselves), so the caller cannot meaningfully attach a
2211            // post-hoc Z transform here.
2212            if transform.is_some() {
2213                gam_problem::bail_invalid_basis!(
2214                    "PCA bases do not expose a composable identifiability transform"
2215                );
2216            }
2217            Ok(BasisMetadata::Pca {
2218                feature_cols: feature_cols.clone(),
2219                basis_matrix: basis_matrix.clone(),
2220                centered: *centered,
2221                smooth_penalty: *smooth_penalty,
2222                center_mean: center_mean.clone(),
2223                pca_basis_path: pca_basis_path.clone(),
2224                chunk_size: *chunk_size,
2225            })
2226        }
2227    }
2228}
2229
2230// `pub` so the #1601-orphaned design-assembly constraint regression guards
2231// (re-homed into gam-models) can assert the realized constraint orthogonality
2232// residual directly against this exact production helper rather than a copy.
2233pub fn orthogonality_relative_residual_for_design(
2234    design: &DesignMatrix,
2235    constraint_matrix: ArrayView2<'_, f64>,
2236) -> Result<f64, BasisError> {
2237    let cross = design_constraint_cross(design, constraint_matrix)?;
2238    let num = cross.iter().map(|v| v * v).sum::<f64>().sqrt();
2239    let b_norm = design_frobenius_norm(design)?;
2240    let c_norm = constraint_matrix.iter().map(|v| v * v).sum::<f64>().sqrt();
2241    let denom = (b_norm * c_norm).max(1e-300);
2242    Ok(num / denom)
2243}
2244
2245#[cfg(test)]
2246mod frozen_linear_term_mass_rebuild_tests {
2247    use super::*;
2248
2249    /// One `double_penalty=true` linear term named `x`, no smooth/random-effect
2250    /// terms — the minimal spec that exercises `linear_function_mass` without
2251    /// dragging in basis construction.
2252    fn one_linear_term_spec() -> TermCollectionSpec {
2253        TermCollectionSpec {
2254            linear_terms: vec![LinearTermSpec {
2255                name: "x".to_string(),
2256                feature_col: 0,
2257                feature_cols: vec![0],
2258                categorical_levels: vec![],
2259                double_penalty: true,
2260                coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
2261                coefficient_min: None,
2262                coefficient_max: None,
2263                frozen_function_mass: None,
2264            }],
2265            random_effect_terms: Vec::new(),
2266            smooth_terms: Vec::new(),
2267        }
2268    }
2269
2270    fn training_data_varying_x(n: usize) -> Array2<f64> {
2271        let mut data = Array2::<f64>::zeros((n, 1));
2272        for i in 0..n {
2273            // Genuinely varying, well away from zero for every row.
2274            data[[i, 0]] = 1.0 + i as f64;
2275        }
2276        data
2277    }
2278
2279    fn constant_zero_x(n_rows: usize) -> Array2<f64> {
2280        Array2::<f64>::zeros((n_rows, 1))
2281    }
2282
2283    /// Sanity check that the guard this fix must NOT weaken is still live: an
2284    /// UNFROZEN spec built directly over a genuinely all-zero column (the
2285    /// fit-time case — `data` really is what will be fit on) still reports the
2286    /// "identically zero" identifiability failure instead of silently fitting
2287    /// an unrecoverable term.
2288    #[test]
2289    fn unfrozen_spec_still_rejects_a_genuinely_zero_training_column() {
2290        let spec = one_linear_term_spec();
2291        let degenerate_training_data = constant_zero_x(20);
2292        let err = build_term_collection_design(degenerate_training_data.view(), &spec)
2293            .expect_err("an unfrozen spec fit directly on an all-zero column must still fail");
2294        let message = err.to_string();
2295        assert!(
2296            message.contains("identically zero"),
2297            "expected the identifiability guard's message, got: {message}"
2298        );
2299    }
2300
2301    /// The regression this fix targets (#1561 rebuild-design triage, shortlist
2302    /// item 1): fit on a TRAINING set where `x` genuinely varies, freeze the
2303    /// spec, then rebuild the design at a small EVALUATION set where `x`
2304    /// happens to be constant (e.g. an anchor grid that holds a covariate
2305    /// fixed to isolate another term's effect — the exact pattern in
2306    /// `quality_vs_mass_ordinal_polr` and
2307    /// `quality_vs_inla_survival_random_intercept_baseline`). The rebuild must
2308    /// succeed and must reuse the TRAINING-time mass rather than recomputing
2309    /// (which would be a bogus "identically zero" recomputed from the
2310    /// constant evaluation rows).
2311    #[test]
2312    fn frozen_spec_rebuilds_at_a_constant_evaluation_column_using_the_training_mass() {
2313        let spec = one_linear_term_spec();
2314        let training_data = training_data_varying_x(40);
2315
2316        let training_design = build_term_collection_design(training_data.view(), &spec)
2317            .expect("fit-time build over a genuinely varying column must succeed");
2318        let training_mass = training_design
2319            .linear_function_masses
2320            .first()
2321            .copied()
2322            .flatten()
2323            .expect("a double_penalty=true term must report its fit-time function mass");
2324        assert!(
2325            training_mass > 0.0,
2326            "training mass for a genuinely varying column must be strictly positive, got {training_mass}"
2327        );
2328
2329        let frozen_spec = freeze_term_collection_from_design(&spec, &training_design)
2330            .expect("freezing the spec against its own fit-time design must succeed");
2331        assert_eq!(
2332            frozen_spec.linear_terms[0].frozen_function_mass,
2333            Some(training_mass),
2334            "freezing must persist the exact fit-time mass onto the term"
2335        );
2336
2337        // The rebuild-time evaluation grid: `x` is constant (zero) across
2338        // every one of these rows, exactly like an anchor/group-anchor probe
2339        // that fixes a covariate to isolate another effect.
2340        let evaluation_grid = constant_zero_x(3);
2341        let rebuilt_design = build_term_collection_design(evaluation_grid.view(), &frozen_spec)
2342            .expect(
2343                "rebuilding a FROZEN spec's design at a constant-covariate evaluation grid must \
2344                 succeed — the training-time mass is reused, never recomputed from these rows",
2345            );
2346        assert_eq!(
2347            rebuilt_design
2348                .linear_function_masses
2349                .first()
2350                .copied()
2351                .flatten(),
2352            Some(training_mass),
2353            "the rebuilt design must carry the REUSED training-time mass, not a value \
2354             recomputed from the (all-zero) evaluation rows"
2355        );
2356    }
2357}