1use 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
27fn 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
73pub 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 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 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 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 linear_ranges.push((linear.name.clone(), col..(col + 1)));
189 }
190
191 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 let mut blocks = Vec::<DesignBlock>::new();
221
222 if p_intercept == 1 {
227 blocks.push(DesignBlock::Intercept(n));
228 }
229
230 if let Some(lin_block) = linear_block {
232 blocks.push(DesignBlock::Dense(
233 gam_linalg::matrix::DenseDesignMatrix::from(lin_block),
234 ));
235 }
236
237 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 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 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 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 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
451pub 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
461pub 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
529pub 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#[derive(Debug, Clone)]
553pub struct TermCollectionDerivativeDesign {
554 pub design: Array2<f64>,
556 pub affine_offset: Array1<f64>,
558}
559
560impl TermCollectionDerivativeDesign {
561 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
588pub 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 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 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 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 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 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
710fn 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 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 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
776fn 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 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 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 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
894fn 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
989pub fn smooth_intrinsic_parametric_feature_cols(
993 linear_terms: &[LinearTermSpec],
994 term: &SmoothTermSpec,
995) -> Vec<usize> {
996 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 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 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 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 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 || 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 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 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 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 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 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 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 let block = ConstructiveQuadratic::from_energy_factor(
1360 s_full.factor().slice(s![.., *plo..*phi]).to_owned(),
1361 "owned global smooth primary block",
1362 )?;
1363 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 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 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 kronecker_factored: None,
1530 joint_null_rotation: None,
1536 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
1605fn 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 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 ¶metric_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
1768fn 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, )?))
1811 }
1812 } else {
1813 Ok(None)
1814 }
1815}
1816
1817fn 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 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 SmoothBasisSpec::ConstantCurvature { spec, .. } => matches!(
1914 spec.identifiability,
1915 ConstantCurvatureIdentifiability::CenterSumToZero
1916 ),
1917 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 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 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 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 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
2230pub 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 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 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 #[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 #[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 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}