Skip to main content

gam_models/gamlss/
builders.rs

1// Real concern-organized submodule of the gamlss family stack.
2// Cross-module items are re-exported flat through the parent (`gamlss.rs`),
3// so `use super::*;` makes the sibling-concern symbols this module references
4// resolve through the parent namespace.
5use super::*;
6
7#[derive(Clone, Copy)]
8pub(crate) struct GamlssLambdaLayout {
9    pub(crate) k_mean: usize,
10    pub(crate) k_noise: usize,
11    pub(crate) kwiggle: usize,
12}
13
14impl GamlssLambdaLayout {
15    pub(crate) fn two_block(k_mean: usize, k_noise: usize) -> Self {
16        Self {
17            k_mean,
18            k_noise,
19            kwiggle: 0,
20        }
21    }
22
23    pub(crate) fn withwiggle(k_mean: usize, k_noise: usize, kwiggle: usize) -> Self {
24        Self {
25            k_mean,
26            k_noise,
27            kwiggle,
28        }
29    }
30
31    pub(crate) fn total(self) -> usize {
32        self.k_mean + self.k_noise + self.kwiggle
33    }
34
35    pub(crate) fn noise_start(self) -> usize {
36        self.k_mean
37    }
38
39    pub(crate) fn noise_end(self) -> usize {
40        self.k_mean + self.k_noise
41    }
42
43    pub(crate) fn wiggle_start(self) -> usize {
44        self.k_mean + self.k_noise
45    }
46
47    pub(crate) fn wiggle_end(self) -> usize {
48        self.k_mean + self.k_noise + self.kwiggle
49    }
50
51    pub(crate) fn validate_theta_len(self, theta_len: usize, context: &str) -> Result<(), String> {
52        let needed = self.total();
53        if theta_len < needed {
54            return Err(GamlssError::DimensionMismatch {
55                reason: format!(
56                    "{context} theta too short: got {}, need at least {}",
57                    theta_len, needed
58                ),
59            }
60            .into());
61        }
62        Ok(())
63    }
64
65    pub(crate) fn mean_from(self, theta: &Array1<f64>) -> Array1<f64> {
66        theta.slice(s![0..self.k_mean]).to_owned()
67    }
68
69    pub(crate) fn noise_from(self, theta: &Array1<f64>) -> Array1<f64> {
70        theta
71            .slice(s![self.noise_start()..self.noise_end()])
72            .to_owned()
73    }
74
75    pub(crate) fn wiggle_from(self, theta: &Array1<f64>) -> Array1<f64> {
76        theta
77            .slice(s![self.wiggle_start()..self.wiggle_end()])
78            .to_owned()
79    }
80}
81
82#[derive(Clone, Copy)]
83pub(crate) struct GamlssBetaLayout {
84    pub(crate) pt: usize,
85    pub(crate) pls: usize,
86    pub(crate) pw: usize,
87}
88
89impl GamlssBetaLayout {
90    pub(crate) fn withwiggle(pt: usize, pls: usize, pw: usize) -> Self {
91        Self { pt, pls, pw }
92    }
93
94    pub(crate) fn total(self) -> usize {
95        self.pt + self.pls + self.pw
96    }
97
98    pub(crate) fn split_three(
99        self,
100        flat: &Array1<f64>,
101        context: &str,
102    ) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
103        if flat.len() != self.total() {
104            return Err(GamlssError::DimensionMismatch {
105                reason: format!(
106                    "{context} length mismatch: got {}, expected {}",
107                    flat.len(),
108                    self.total()
109                ),
110            }
111            .into());
112        }
113        Ok((
114            flat.slice(s![0..self.pt]).to_owned(),
115            flat.slice(s![self.pt..self.pt + self.pls]).to_owned(),
116            flat.slice(s![self.pt + self.pls..self.total()]).to_owned(),
117        ))
118    }
119}
120
121#[derive(Clone, Debug)]
122pub struct FamilyMetadata {
123    pub name: &'static str,
124    pub parameternames: &'static [&'static str],
125    pub parameter_links: &'static [ParameterLink],
126}
127
128pub(crate) const DEFAULT_GAUGE_PRIORITY: u8 = 100;
129
130pub(crate) const LINK_WIGGLE_GAUGE_PRIORITY: u8 = 80;
131
132pub(crate) fn initial_log_lambdas_orzeros(
133    block: &ParameterBlockInput,
134) -> Result<Array1<f64>, String> {
135    let k = block.penalties.len();
136    let lambdas = block
137        .initial_log_lambdas
138        .clone()
139        .unwrap_or_else(|| Array1::<f64>::zeros(k));
140    if lambdas.len() != k {
141        return Err(GamlssError::DimensionMismatch {
142            reason: format!(
143                "initial_log_lambdas length mismatch: got {}, expected {}",
144                lambdas.len(),
145                k
146            ),
147        }
148        .into());
149    }
150    gam_problem::validate_log_strengths(lambdas.iter().copied())
151        .map_err(|error| format!("initial_log_lambdas: {error}"))?;
152    Ok(lambdas)
153}
154
155fn fitted_log_lambdas(lambdas: &Array1<f64>, context: &str) -> Result<Array1<f64>, String> {
156    lambdas
157        .iter()
158        .copied()
159        .enumerate()
160        .map(|(coordinate, value)| {
161            gam_problem::checked_log_strength(value)
162                .map_err(|error| format!("{context} coordinate {coordinate}: {error}"))
163        })
164        .collect::<Result<Vec<_>, _>>()
165        .map(Array1::from_vec)
166}
167
168pub(crate) fn build_two_block_exact_joint_setup(
169    data: ArrayView2<'_, f64>,
170    meanspec: &TermCollectionSpec,
171    noisespec: &TermCollectionSpec,
172    mean_penalties: usize,
173    noise_penalties: usize,
174    extra_rho0: &[f64],
175    rho0_override: Option<&Array1<f64>>,
176    kappa_options: &SpatialLengthScaleOptimizationOptions,
177) -> Result<ExactJointHyperSetup, gam_terms::basis::BasisError> {
178    // GAMLSS-specific part: assemble the rho seed in [mean | noise | extra]
179    // penalty order, honoring a caller override when it matches the layout.
180    let rho_dim = mean_penalties + noise_penalties + extra_rho0.len();
181    let mut rho0vec = Array1::<f64>::zeros(rho_dim);
182    if let Some(rho0) = rho0_override.filter(|rho0| rho0.len() == rho_dim) {
183        rho0vec.assign(rho0);
184    } else {
185        for (i, &rho_init) in extra_rho0.iter().enumerate() {
186            rho0vec[mean_penalties + noise_penalties + i] = rho_init;
187        }
188    }
189
190    // Generic part: per-block log(kappa) seed/bounds and exact-joint assembly,
191    // with the two linear predictors (mean, noise) in theta order.
192    build_location_scale_exact_joint_setup(data, &[meanspec, noisespec], rho0vec, kappa_options)
193}
194
195pub(crate) fn gaussian_location_scalewarm_start(
196    y: &Array1<f64>,
197    weights: &Array1<f64>,
198    mu_block: &ParameterBlockSpec,
199    log_sigma_block: &ParameterBlockSpec,
200    ridge_floor: f64,
201    mean_beta_hint: Option<&Array1<f64>>,
202    noise_beta_hint: Option<&Array1<f64>>,
203) -> Result<(Array1<f64>, Array1<f64>, f64), String> {
204    let betamu = if let Some(beta) = mean_beta_hint {
205        beta.clone()
206    } else {
207        solve_penalizedweighted_projection(
208            &mu_block.design,
209            &mu_block.offset,
210            y,
211            weights,
212            &mu_block.penalties,
213            &mu_block.initial_log_lambdas,
214            ridge_floor,
215        )?
216    };
217    let mut mu_hat = mu_block.solver_design().matrixvectormultiply(&betamu);
218    mu_hat += mu_block.solver_offset();
219    let mut weighted_ss = 0.0;
220    let mut weight_sum = 0.0;
221    for i in 0..y.len() {
222        let wi = weights[i].max(0.0);
223        let resid = y[i] - mu_hat[i];
224        weighted_ss += wi * resid * resid;
225        weight_sum += wi;
226    }
227    if !weighted_ss.is_finite() || !weight_sum.is_finite() || weight_sum <= 0.0 {
228        return Err(
229            "gaussian location-scale warm start could not estimate residual scale".to_string(),
230        );
231    }
232    // Warm-start σ̂ must clear the logb floor so the inverse link
233    //   η = log(σ − b)
234    // is finite. Use a relative cushion above b so the warm-start is in the
235    // smooth interior of the link domain.
236    let sigma_hat = (weighted_ss / weight_sum)
237        .sqrt()
238        .max(LOGB_SIGMA_FLOOR * 1.5);
239    let beta_log_sigma = if let Some(beta) = noise_beta_hint {
240        beta.clone()
241    } else {
242        let eta_sigma = (sigma_hat - LOGB_SIGMA_FLOOR).ln();
243        let sigma_target = Array1::from_elem(y.len(), eta_sigma);
244        solve_penalizedweighted_projection(
245            &log_sigma_block.design,
246            &log_sigma_block.offset,
247            &sigma_target,
248            weights,
249            &log_sigma_block.penalties,
250            &log_sigma_block.initial_log_lambdas,
251            ridge_floor,
252        )?
253    };
254    Ok((betamu, beta_log_sigma, sigma_hat))
255}
256
257/// Total output count for every two-block location-scale family in this
258/// module (mu/log_sigma or threshold/log_sigma). The wiggle variants add a
259/// third zero-channel block but still drive only two output channels.
260pub(crate) const LOCATION_SCALE_N_OUTPUTS: usize = 2;
261
262/// Construct a fully wired location-scale parameter block.
263///
264/// This is the **only** way to build a LocationScale `ParameterBlockSpec` in
265/// this module — by construction the `AdditiveBlockJacobian` callback is
266/// always installed, so the channel-aware identifiability audit cannot be
267/// silently bypassed by a future `build_blocks` impl that forgets to wire
268/// the callback at the tail (re-introducing #319).
269///
270/// `own_output` is the zero-based output channel this block drives
271/// (e.g. 0 for `mu`/`threshold`, 1 for `log_sigma`). `n_family_outputs` is
272/// fixed at [`LOCATION_SCALE_N_OUTPUTS`] for every two-block family here
273/// but is exposed so the helper composes cleanly with any future
274/// k-block extension.
275pub(crate) fn build_location_scale_block(
276    name: impl Into<String>,
277    design: DesignMatrix,
278    offset: Array1<f64>,
279    penalties: Vec<PenaltyMatrix>,
280    nullspace_dims: Vec<usize>,
281    initial_log_lambdas: Array1<f64>,
282    initial_beta: Option<Array1<f64>>,
283    own_output: usize,
284    n_family_outputs: usize,
285    caller: &str,
286) -> Result<ParameterBlockSpec, String> {
287    if own_output >= n_family_outputs {
288        return Err(format!(
289            "{caller}: own_output={own_output} >= n_family_outputs={n_family_outputs}"
290        ));
291    }
292    let mut spec = ParameterBlockSpec {
293        name: name.into(),
294        design,
295        offset,
296        penalties,
297        nullspace_dims,
298        initial_log_lambdas,
299        initial_beta,
300        gauge_priority: 100,
301        jacobian_callback: None,
302        stacked_design: None,
303        stacked_offset: None,
304    };
305    let dense = spec.effective_design(caller)?;
306    spec.jacobian_callback = Some(std::sync::Arc::new(AdditiveBlockJacobian {
307        design: dense,
308        own_output,
309        n_family_outputs,
310    }));
311    Ok(spec)
312}
313
314/// Construct the wiggle block that accompanies a two-block location-scale
315/// family. The wiggle modulates the inverse link nonlinearly and
316/// contributes no linear effective Jacobian — the installed callback
317/// therefore exposes a zero `(n × p_w)` design under
318/// `n_family_outputs = LOCATION_SCALE_N_OUTPUTS`.
319pub(crate) fn build_location_scale_wiggle_block(
320    name: impl Into<String>,
321    design: DesignMatrix,
322    offset: Array1<f64>,
323    penalties: Vec<PenaltyMatrix>,
324    nullspace_dims: Vec<usize>,
325    initial_log_lambdas: Array1<f64>,
326    initial_beta: Option<Array1<f64>>,
327    n_rows: usize,
328) -> Result<ParameterBlockSpec, String> {
329    let p_w = design.ncols();
330    let mut spec = ParameterBlockSpec {
331        name: name.into(),
332        design,
333        offset,
334        penalties,
335        nullspace_dims,
336        initial_log_lambdas,
337        initial_beta,
338        gauge_priority: 100,
339        jacobian_callback: None,
340        stacked_design: None,
341        stacked_offset: None,
342    };
343    spec.jacobian_callback = Some(std::sync::Arc::new(AdditiveBlockJacobian {
344        design: ndarray::Array2::<f64>::zeros((n_rows, p_w)),
345        own_output: 0,
346        n_family_outputs: LOCATION_SCALE_N_OUTPUTS,
347    }));
348    Ok(spec)
349}
350
351pub(crate) fn prepared_gaussian_log_sigma_design(
352    mu_design: &DesignMatrix,
353    log_sigma_design: &DesignMatrix,
354) -> Result<DesignMatrix, String> {
355    if mu_design.nrows() != log_sigma_design.nrows() {
356        return Err(GamlssError::DimensionMismatch {
357            reason: format!(
358                "gaussian log-sigma design row mismatch: mean rows={}, log_sigma rows={}",
359                mu_design.nrows(),
360                log_sigma_design.nrows()
361            ),
362        }
363        .into());
364    }
365    // Gaussian location-scale remains identifiable even when μ and log σ use
366    // the same covariate basis:
367    //
368    //   L(μ, η) = 0.5 * Σ_i [ (y_i - μ_i)^2 exp(-2η_i) + 2η_i ],
369    //   μ = X_μ β_μ,  η = X_σ β_σ.
370    //
371    // Shared columns are not a frame mismatch. β_μ and β_σ enter through
372    // different sufficient statistics (residual and residual²), so replacing
373    // X_σ with (I - P_{X_μ}) X_σ would impose an extra constraint and can
374    // erase real heteroscedastic signal when the two blocks share a basis.
375    Ok(log_sigma_design.clone())
376}
377
378pub(crate) fn identified_binomial_log_sigma_design(
379    threshold_design: &TermCollectionDesign,
380    log_sigma_design: &TermCollectionDesign,
381    weights: &Array1<f64>,
382) -> Result<DesignMatrix, String> {
383    let non_intercept_start = log_sigma_design
384        .intercept_range
385        .end
386        .min(log_sigma_design.design.ncols());
387    let transform = build_scale_deviation_transform_design(
388        &threshold_design.design,
389        &log_sigma_design.design,
390        weights,
391        non_intercept_start,
392    )?;
393    build_scale_deviation_operator(
394        threshold_design.design.clone(),
395        log_sigma_design.design.clone(),
396        &transform,
397    )
398}
399
400pub(crate) fn identity_penalty(dim: usize) -> Array2<f64> {
401    let mut penalty = Array2::<f64>::zeros((dim, dim));
402    for i in 0..dim {
403        penalty[[i, i]] = 1.0;
404    }
405    penalty
406}
407
408pub(crate) fn append_binomial_log_sigma_shrinkage_penalty_design(
409    design: &mut TermCollectionDesign,
410) {
411    let p = design.design.ncols();
412    design
413        .penalties
414        .push(BlockwisePenalty::new(0..p, identity_penalty(p)));
415    // Identity penalty penalizes the full space → nullspace dimension is 0.
416    design.nullspace_dims.push(0);
417    design.penaltyinfo.push(PenaltyBlockInfo {
418        global_index: design.penaltyinfo.len(),
419        termname: Some("log_sigma_shrinkage".to_string()),
420        penalty: ActivePenaltyInfo {
421            source: PenaltySource::Other("shrinkage".to_string()),
422            original_index: 0,
423            effective_rank: p,
424            normalization_scale: 1.0,
425            kronecker_factors: None,
426            structural_null_frame: None,
427        },
428    });
429}
430
431/// Build the (mean, log-σ) parameter-block pair for a Gaussian location-scale
432/// family. Shared verbatim by the non-wiggle and wiggle Gaussian builders so the
433/// scale-block construction — prepared log-σ design, formula-native penalties,
434/// and the joint Gaussian warm start — lives in exactly one place. Callers
435/// supply the per-block log-λ vectors sliced from their own layout (two-block vs
436/// with-wiggle) and append any extra blocks.
437pub(crate) fn build_gaussian_mean_and_scale_blocks(
438    y: &Array1<f64>,
439    weights: &Array1<f64>,
440    mean_design: &TermCollectionDesign,
441    noise_design: &TermCollectionDesign,
442    mean_offset: &Array1<f64>,
443    noise_offset: &Array1<f64>,
444    mean_log_lambdas: Array1<f64>,
445    noise_log_lambdas: Array1<f64>,
446    mean_beta_hint: Option<Array1<f64>>,
447    noise_beta_hint: Option<Array1<f64>>,
448    context: &str,
449) -> Result<(ParameterBlockSpec, ParameterBlockSpec), String> {
450    let mean_offset = mean_design
451        .compose_offset(mean_offset.view(), &format!("{context}: mu"))
452        .map_err(|error| error.to_string())?;
453    let noise_offset = noise_design
454        .compose_offset(noise_offset.view(), &format!("{context}: log_sigma"))
455        .map_err(|error| error.to_string())?;
456    let mut meanspec = build_location_scale_block(
457        "mu",
458        mean_design.design.clone(),
459        mean_offset,
460        mean_design.penalties_as_penalty_matrix(),
461        mean_design.nullspace_dims.clone(),
462        mean_log_lambdas,
463        mean_beta_hint,
464        0,
465        LOCATION_SCALE_N_OUTPUTS,
466        &format!("{context}: mu"),
467    )?;
468    let prepared_noise_design =
469        prepared_gaussian_log_sigma_design(&mean_design.design, &noise_design.design)?;
470    // The formula-native penalty topology is authoritative. Smooth terms carry
471    // their own REML-selected null-space penalty when `double_penalty=true`
472    // (the default), while an explicit `double_penalty=false` remains a real
473    // opt-out. In particular the global log-σ intercept is likelihood-identified
474    // and must stay unpenalized: adding a Gaussian-only projector over the joint
475    // null space placed a data-scale-dependent prior on the overall σ level and
476    // introduced an extra smoothing coordinate absent from the formula (#1561).
477    let mut noisespec = build_location_scale_block(
478        "log_sigma",
479        prepared_noise_design,
480        noise_offset,
481        noise_design.penalties_as_penalty_matrix(),
482        noise_design.nullspace_dims.clone(),
483        noise_log_lambdas,
484        noise_beta_hint,
485        1,
486        LOCATION_SCALE_N_OUTPUTS,
487        &format!("{context}: log_sigma"),
488    )?;
489    if meanspec.initial_beta.is_none() || noisespec.initial_beta.is_none() {
490        let (betamu0, beta_ls0, _) = gaussian_location_scalewarm_start(
491            y,
492            weights,
493            &meanspec,
494            &noisespec,
495            1e-10,
496            meanspec.initial_beta.as_ref(),
497            noisespec.initial_beta.as_ref(),
498        )?;
499        if meanspec.initial_beta.is_none() {
500            meanspec.initial_beta = Some(betamu0);
501        }
502        if noisespec.initial_beta.is_none() {
503            noisespec.initial_beta = Some(beta_ls0);
504        }
505    }
506    Ok((meanspec, noisespec))
507}
508
509/// Build the (threshold, log-σ) parameter-block pair for a Binomial
510/// location-scale family. Shared by the non-wiggle and wiggle Binomial builders;
511/// mirrors [`build_gaussian_mean_and_scale_blocks`] but with the binomial-
512/// identified log-σ design, the link-aware joint warm start, and the same
513/// REML-selected full-span scale shrinkage penalty.
514pub(crate) fn build_binomial_threshold_and_scale_blocks(
515    y: &Array1<f64>,
516    weights: &Array1<f64>,
517    link_kind: &InverseLink,
518    mean_design: &TermCollectionDesign,
519    noise_design: &TermCollectionDesign,
520    mean_offset: &Array1<f64>,
521    noise_offset: &Array1<f64>,
522    mean_log_lambdas: Array1<f64>,
523    noise_log_lambdas: Array1<f64>,
524    mean_beta_hint: Option<Array1<f64>>,
525    noise_beta_hint: Option<Array1<f64>>,
526    context: &str,
527) -> Result<(ParameterBlockSpec, ParameterBlockSpec), String> {
528    let mean_offset = mean_design
529        .compose_offset(mean_offset.view(), &format!("{context}: threshold"))
530        .map_err(|error| error.to_string())?;
531    let noise_offset = noise_design
532        .compose_offset(noise_offset.view(), &format!("{context}: log_sigma"))
533        .map_err(|error| error.to_string())?;
534    let identifiednoise_design =
535        identified_binomial_log_sigma_design(mean_design, noise_design, weights)?;
536    let p_noise = identifiednoise_design.ncols();
537    let mut log_sigma_penalty_matrices: Vec<PenaltyMatrix> =
538        noise_design.penalties_as_penalty_matrix();
539    log_sigma_penalty_matrices.push(PenaltyMatrix::Dense(identity_penalty(p_noise)));
540    let mut thresholdspec = build_location_scale_block(
541        "threshold",
542        mean_design.design.clone(),
543        mean_offset,
544        mean_design.penalties_as_penalty_matrix(),
545        vec![],
546        mean_log_lambdas,
547        mean_beta_hint,
548        0,
549        LOCATION_SCALE_N_OUTPUTS,
550        &format!("{context}: threshold"),
551    )?;
552    let mut log_sigmaspec = build_location_scale_block(
553        "log_sigma",
554        identifiednoise_design,
555        noise_offset,
556        log_sigma_penalty_matrices,
557        vec![],
558        noise_log_lambdas,
559        noise_beta_hint,
560        1,
561        LOCATION_SCALE_N_OUTPUTS,
562        &format!("{context}: log_sigma"),
563    )?;
564    if thresholdspec.initial_beta.is_none() || log_sigmaspec.initial_beta.is_none() {
565        let (beta_t0, beta_ls0) = binomial_location_scalewarm_start(
566            y,
567            weights,
568            link_kind,
569            &thresholdspec,
570            &log_sigmaspec,
571            thresholdspec.initial_beta.as_ref(),
572            log_sigmaspec.initial_beta.as_ref(),
573        )?;
574        if thresholdspec.initial_beta.is_none() {
575            thresholdspec.initial_beta = Some(beta_t0);
576        }
577        if log_sigmaspec.initial_beta.is_none() {
578            log_sigmaspec.initial_beta = Some(beta_ls0);
579        }
580    }
581    Ok((thresholdspec, log_sigmaspec))
582}
583
584/// Convert a wiggle block's `PenaltySpec`s into the `PenaltyMatrix` list the
585/// location-scale wiggle block expects. Shared by the Gaussian and Binomial
586/// wiggle builders, which previously inlined the identical match.
587pub(crate) fn wiggle_block_penalty_matrices(
588    wiggle_block: &ParameterBlockInput,
589) -> Vec<PenaltyMatrix> {
590    let p_wiggle = wiggle_block.design.ncols();
591    wiggle_block
592        .penalties
593        .iter()
594        .map(|spec| match spec {
595            crate::model_types::PenaltySpec::Block {
596                local, col_range, ..
597            } => PenaltyMatrix::Blockwise {
598                local: local.clone(),
599                col_range: col_range.clone(),
600                total_dim: p_wiggle,
601            },
602            crate::model_types::PenaltySpec::Dense(m)
603            | crate::model_types::PenaltySpec::DenseWithMean { matrix: m, .. } => {
604                PenaltyMatrix::Dense(m.clone())
605            }
606        })
607        .collect()
608}
609
610pub(crate) fn binomial_location_scale_link_eta_from_probability(
611    link_kind: &InverseLink,
612    probability: f64,
613) -> Result<f64, String> {
614    let target = probability.clamp(1e-6, 1.0 - 1e-6);
615    match link_kind {
616        InverseLink::Standard(StandardLink::Logit) => Ok((target / (1.0 - target)).ln()),
617        InverseLink::Standard(StandardLink::Probit) => standard_normal_quantile(target)
618            .map_err(|err| format!("failed to invert probit warm-start probability: {err}")),
619        InverseLink::Standard(StandardLink::CLogLog) => Ok((-((1.0 - target).ln())).ln()),
620        other => Err(GamlssError::UnsupportedConfiguration { reason: format!(
621            "binomial location-scale warm start requires logit, probit, or cloglog link, got {other:?}"
622        ) }.into()),
623    }
624}
625
626pub(crate) fn weighted_binomial_prevalence(
627    y: &Array1<f64>,
628    weights: &Array1<f64>,
629) -> Result<f64, String> {
630    if y.len() != weights.len() {
631        return Err(GamlssError::DimensionMismatch { reason: format!(
632            "binomial location-scale warm start dimension mismatch: y has length {}, weights have length {}",
633            y.len(),
634            weights.len()
635        ) }.into());
636    }
637    let mut weight_sum = 0.0;
638    let mut success_sum = 0.0;
639    for (&yi, &wi) in y.iter().zip(weights.iter()) {
640        if !yi.is_finite() {
641            return Err(GamlssError::NonFinite {
642                reason: format!(
643                    "binomial location-scale warm start encountered non-finite response {yi}"
644                ),
645            }
646            .into());
647        }
648        if !wi.is_finite() || wi < 0.0 {
649            return Err(GamlssError::InvalidInput {
650                reason: format!(
651                    "binomial location-scale warm start requires finite non-negative weights; weight={wi}"
652                ),
653            }
654            .into());
655        }
656        if wi > 0.0 {
657            weight_sum += wi;
658            success_sum += wi * yi;
659        }
660    }
661    if !weight_sum.is_finite() || weight_sum <= 0.0 {
662        return Err(
663            "binomial location-scale warm start requires positive total weight".to_string(),
664        );
665    }
666    Ok(success_sum / weight_sum)
667}
668
669pub(crate) fn project_constant_eta_into_block(
670    block: &ParameterBlockSpec,
671    weights: &Array1<f64>,
672    eta: f64,
673) -> Result<Array1<f64>, String> {
674    let target_eta = Array1::from_elem(block.design.nrows(), eta);
675    solve_penalizedweighted_projection(
676        &block.design,
677        &block.offset,
678        &target_eta,
679        weights,
680        &block.penalties,
681        &block.initial_log_lambdas,
682        1e-10,
683    )
684}
685
686// Deterministic warm start for the binomial location-scale model. This stays
687// out of the optimizer: it projects a prevalence-matched threshold and neutral
688// log-sigma value into the actual penalized block spaces.
689pub(crate) fn binomial_location_scalewarm_start(
690    y: &Array1<f64>,
691    weights: &Array1<f64>,
692    link_kind: &InverseLink,
693    threshold_block: &ParameterBlockSpec,
694    log_sigma_block: &ParameterBlockSpec,
695    mean_beta_hint: Option<&Array1<f64>>,
696    noise_beta_hint: Option<&Array1<f64>>,
697) -> Result<(Array1<f64>, Array1<f64>), String> {
698    if let (Some(mean_beta), Some(noise_beta)) = (mean_beta_hint, noise_beta_hint) {
699        return Ok((mean_beta.clone(), noise_beta.clone()));
700    }
701
702    let beta_threshold = match mean_beta_hint {
703        Some(beta) => beta.clone(),
704        None => {
705            let prevalence = weighted_binomial_prevalence(y, weights)?;
706            let eta = binomial_location_scale_link_eta_from_probability(link_kind, prevalence)?;
707            project_constant_eta_into_block(threshold_block, weights, eta)?
708        }
709    };
710    let beta_log_sigma = match noise_beta_hint {
711        Some(beta) => beta.clone(),
712        None => project_constant_eta_into_block(log_sigma_block, weights, 0.0)?,
713    };
714    Ok((beta_threshold, beta_log_sigma))
715}
716
717#[derive(Clone)]
718pub(crate) struct BinomialMeanWiggleSpec {
719    pub y: Array1<f64>,
720    pub weights: Array1<f64>,
721    pub link_kind: InverseLink,
722    pub wiggle_knots: Array1<f64>,
723    pub wiggle_degree: usize,
724    pub eta_block: ParameterBlockInput,
725    pub wiggle_block: ParameterBlockInput,
726}
727
728#[derive(Clone)]
729pub struct GaussianLocationScaleTermSpec {
730    pub y: Array1<f64>,
731    pub weights: Array1<f64>,
732    pub meanspec: TermCollectionSpec,
733    pub log_sigmaspec: TermCollectionSpec,
734    pub mean_offset: Array1<f64>,
735    pub log_sigma_offset: Array1<f64>,
736}
737
738#[derive(Clone)]
739pub struct GaussianLocationScaleWiggleTermSpec {
740    pub y: Array1<f64>,
741    pub weights: Array1<f64>,
742    pub meanspec: TermCollectionSpec,
743    pub log_sigmaspec: TermCollectionSpec,
744    pub mean_offset: Array1<f64>,
745    pub log_sigma_offset: Array1<f64>,
746    pub wiggle_knots: Array1<f64>,
747    pub wiggle_degree: usize,
748    pub wiggle_block: ParameterBlockInput,
749}
750
751#[derive(Clone)]
752pub struct BinomialLocationScaleTermSpec {
753    pub y: Array1<f64>,
754    pub weights: Array1<f64>,
755    pub link_kind: InverseLink,
756    pub thresholdspec: TermCollectionSpec,
757    pub log_sigmaspec: TermCollectionSpec,
758    pub threshold_offset: Array1<f64>,
759    pub log_sigma_offset: Array1<f64>,
760}
761
762#[derive(Clone)]
763pub struct BinomialLocationScaleWiggleTermSpec {
764    pub y: Array1<f64>,
765    pub weights: Array1<f64>,
766    pub link_kind: InverseLink,
767    pub thresholdspec: TermCollectionSpec,
768    pub log_sigmaspec: TermCollectionSpec,
769    pub threshold_offset: Array1<f64>,
770    pub log_sigma_offset: Array1<f64>,
771    pub wiggle_knots: Array1<f64>,
772    pub wiggle_degree: usize,
773    pub wiggle_block: ParameterBlockInput,
774}
775
776#[derive(Clone, Debug)]
777pub struct BlockwiseTermFitResult {
778    pub fit: UnifiedFitResult,
779    pub meanspec_resolved: TermCollectionSpec,
780    pub noisespec_resolved: TermCollectionSpec,
781    pub mean_design: TermCollectionDesign,
782    pub noise_design: TermCollectionDesign,
783}
784
785pub(crate) struct BlockwiseTermFitResultParts {
786    pub fit: UnifiedFitResult,
787    pub meanspec_resolved: TermCollectionSpec,
788    pub noisespec_resolved: TermCollectionSpec,
789    pub mean_design: TermCollectionDesign,
790    pub noise_design: TermCollectionDesign,
791}
792
793pub struct BlockwiseTermWiggleFitResult {
794    pub fit: BlockwiseTermFitResult,
795    pub wiggle_knots: Array1<f64>,
796    pub wiggle_degree: usize,
797}
798
799pub struct BinomialMeanWiggleTermFitResult {
800    pub fit: UnifiedFitResult,
801    pub resolvedspec: TermCollectionSpec,
802    pub design: TermCollectionDesign,
803    pub wiggle_knots: Array1<f64>,
804    pub wiggle_degree: usize,
805    /// Standard I-spline warp coefficients `β_w` for the saved-model predict
806    /// runtime when frozen-basis de-aliasing engaged (#1596). Observation-space
807    /// residualization preserves this coefficient chart, so the fit and predict
808    /// runtime consume the same non-negative vector.
809    pub saved_warp_beta: Option<Vec<f64>>,
810    /// Frozen-index mean-coordinate shift `s = β_frozen_source − β_saved` for the
811    /// predict runtime (#2141). Predict evaluates the warp basis at
812    /// `X·(β_saved + s) = η̂` (the frozen index the fit pinned `B` at) instead of
813    /// the de-aliased base predictor `X·β_saved`, reproducing the fitted `q`.
814    pub saved_index_shift: Option<Vec<f64>>,
815}
816
817pub(crate) struct BlockwiseTermWiggleFitResultParts {
818    pub fit: BlockwiseTermFitResult,
819    pub wiggle_knots: Array1<f64>,
820    pub wiggle_degree: usize,
821}
822
823pub(crate) fn validate_term_collection_design(
824    label: &str,
825    design: &TermCollectionDesign,
826) -> Result<(), String> {
827    let p = design.design.ncols();
828    let n = design.design.nrows();
829    for rows in exact_design_row_chunks(n, p) {
830        let chunk = design
831            .design
832            .try_row_chunk(rows)
833            .map_err(|e| format!("{label}.design row chunk materialization failed: {e}"))?;
834        validate_all_finite_estimation(&format!("{label}.design"), chunk.iter().copied())
835            .map_err(|e| e.to_string())?;
836    }
837    if design.nullspace_dims.len() != design.penalties.len() {
838        return Err(GamlssError::DimensionMismatch {
839            reason: format!(
840                "{label}.nullspace_dims length mismatch: got {}, expected {}",
841                design.nullspace_dims.len(),
842                design.penalties.len()
843            ),
844        }
845        .into());
846    }
847    if design.penaltyinfo.len() != design.penalties.len() {
848        return Err(GamlssError::DimensionMismatch {
849            reason: format!(
850                "{label}.penaltyinfo length mismatch: got {}, expected {}",
851                design.penaltyinfo.len(),
852                design.penalties.len()
853            ),
854        }
855        .into());
856    }
857    for (idx, bp) in design.penalties.iter().enumerate() {
858        validate_all_finite_estimation(
859            &format!("{label}.penalties[{idx}]"),
860            bp.local.iter().copied(),
861        )
862        .map_err(|e| e.to_string())?;
863        if bp.col_range.end > p {
864            return Err(GamlssError::DimensionMismatch {
865                reason: format!(
866                    "{label}.penalties[{idx}] col_range {}..{} exceeds design width {}",
867                    bp.col_range.start, bp.col_range.end, p
868                ),
869            }
870            .into());
871        }
872    }
873    if let Some(bounds) = design.coefficient_lower_bounds.as_ref() {
874        if bounds.len() != p {
875            return Err(GamlssError::ConstraintViolation {
876                reason: format!(
877                    "{label}.coefficient_lower_bounds length mismatch: got {}, expected {p}",
878                    bounds.len()
879                ),
880            }
881            .into());
882        }
883        for (idx, &bound) in bounds.iter().enumerate() {
884            if !(bound.is_finite() || bound == f64::NEG_INFINITY) {
885                return Err(GamlssError::NonFinite { reason: format!(
886                    "{label}.coefficient_lower_bounds[{idx}] must be finite or -inf, got {bound}",
887                ) }.into());
888            }
889        }
890    }
891    if let Some(constraints) = design.linear_constraints.as_ref() {
892        validate_all_finite_estimation(
893            &format!("{label}.linear_constraints.a"),
894            constraints.a.iter().copied(),
895        )
896        .map_err(|e| e.to_string())?;
897        validate_all_finite_estimation(
898            &format!("{label}.linear_constraints.b"),
899            constraints.b.iter().copied(),
900        )
901        .map_err(|e| e.to_string())?;
902        if constraints.a.ncols() != p {
903            return Err(GamlssError::DimensionMismatch {
904                reason: format!(
905                    "{label}.linear_constraints.a column mismatch: got {}, expected {p}",
906                    constraints.a.ncols()
907                ),
908            }
909            .into());
910        }
911        if constraints.a.nrows() != constraints.b.len() {
912            return Err(GamlssError::DimensionMismatch {
913                reason: format!(
914                    "{label}.linear_constraints row mismatch: a has {}, b has {}",
915                    constraints.a.nrows(),
916                    constraints.b.len()
917                ),
918            }
919            .into());
920        }
921    }
922    if design.intercept_range.start > design.intercept_range.end || design.intercept_range.end > p {
923        return Err(GamlssError::ConstraintViolation {
924            reason: format!(
925                "{label}.intercept_range out of bounds: {:?} for {} columns",
926                design.intercept_range, p
927            ),
928        }
929        .into());
930    }
931    Ok(())
932}
933
934impl BlockwiseTermFitResult {
935    pub(crate) fn try_from_parts(parts: BlockwiseTermFitResultParts) -> Result<Self, String> {
936        let BlockwiseTermFitResultParts {
937            fit,
938            meanspec_resolved,
939            noisespec_resolved,
940            mean_design,
941            noise_design,
942        } = parts;
943
944        fit.validate_numeric_finiteness()
945            .map_err(|e| format!("{e}"))?;
946        if fit.block_states.len() < 2 {
947            return Err(GamlssError::DimensionMismatch {
948                reason: format!(
949                    "BlockwiseTermFitResult requires at least 2 block states, got {}",
950                    fit.block_states.len()
951                ),
952            }
953            .into());
954        }
955        validate_term_collection_design("blockwise_term.mean_design", &mean_design)?;
956        validate_term_collection_design("blockwise_term.noise_design", &noise_design)?;
957        if mean_design.design.nrows() != noise_design.design.nrows() {
958            return Err(GamlssError::DimensionMismatch {
959                reason: format!(
960                    "BlockwiseTermFitResult row mismatch: mean_design={}, noise_design={}",
961                    mean_design.design.nrows(),
962                    noise_design.design.nrows()
963                ),
964            }
965            .into());
966        }
967        if fit.block_states[0].beta.len() != mean_design.design.ncols() {
968            return Err(GamlssError::DimensionMismatch {
969                reason: format!(
970                    "BlockwiseTermFitResult mean beta length mismatch: got {}, expected {}",
971                    fit.block_states[0].beta.len(),
972                    mean_design.design.ncols()
973                ),
974            }
975            .into());
976        }
977        if fit.block_states[1].beta.len() != noise_design.design.ncols() {
978            return Err(GamlssError::DimensionMismatch {
979                reason: format!(
980                    "BlockwiseTermFitResult noise beta length mismatch: got {}, expected {}",
981                    fit.block_states[1].beta.len(),
982                    noise_design.design.ncols()
983                ),
984            }
985            .into());
986        }
987        if fit.block_states[0].eta.len() != mean_design.design.nrows() {
988            return Err(GamlssError::DimensionMismatch {
989                reason: format!(
990                    "BlockwiseTermFitResult mean eta length mismatch: got {}, expected {}",
991                    fit.block_states[0].eta.len(),
992                    mean_design.design.nrows()
993                ),
994            }
995            .into());
996        }
997        if fit.block_states[1].eta.len() != noise_design.design.nrows() {
998            return Err(GamlssError::DimensionMismatch {
999                reason: format!(
1000                    "BlockwiseTermFitResult noise eta length mismatch: got {}, expected {}",
1001                    fit.block_states[1].eta.len(),
1002                    noise_design.design.nrows()
1003                ),
1004            }
1005            .into());
1006        }
1007
1008        Ok(Self {
1009            fit,
1010            meanspec_resolved,
1011            noisespec_resolved,
1012            mean_design,
1013            noise_design,
1014        })
1015    }
1016
1017    pub(crate) fn validate_numeric_finiteness(&self) -> Result<(), String> {
1018        Self::try_from_parts(BlockwiseTermFitResultParts {
1019            fit: self.fit.clone(),
1020            meanspec_resolved: self.meanspec_resolved.clone(),
1021            noisespec_resolved: self.noisespec_resolved.clone(),
1022            mean_design: self.mean_design.clone(),
1023            noise_design: self.noise_design.clone(),
1024        })
1025        .map(|_| ())
1026    }
1027}
1028
1029impl BlockwiseTermWiggleFitResult {
1030    pub(crate) fn try_from_parts(parts: BlockwiseTermWiggleFitResultParts) -> Result<Self, String> {
1031        let BlockwiseTermWiggleFitResultParts {
1032            fit,
1033            wiggle_knots,
1034            wiggle_degree,
1035        } = parts;
1036
1037        fit.validate_numeric_finiteness()
1038            .map_err(|e| e.to_string())?;
1039        if fit.fit.block_states.len() < 3 {
1040            return Err(GamlssError::DimensionMismatch {
1041                reason: format!(
1042                    "BlockwiseTermWiggleFitResult requires at least 3 block states, got {}",
1043                    fit.fit.block_states.len()
1044                ),
1045            }
1046            .into());
1047        }
1048        if wiggle_knots.is_empty() {
1049            return Err(GamlssError::UnsupportedConfiguration {
1050                reason: "BlockwiseTermWiggleFitResult requires non-empty wiggle_knots".to_string(),
1051            }
1052            .into());
1053        }
1054        validate_all_finite_estimation(
1055            "blockwise_term_wiggle.wiggle_knots",
1056            wiggle_knots.iter().copied(),
1057        )
1058        .map_err(|e| e.to_string())?;
1059
1060        Ok(Self {
1061            fit,
1062            wiggle_knots,
1063            wiggle_degree,
1064        })
1065    }
1066}
1067
1068pub struct BinomialLocationScaleFitResult {
1069    pub fit: BlockwiseTermFitResult,
1070    pub wiggle_knots: Option<Array1<f64>>,
1071    pub wiggle_degree: Option<usize>,
1072    pub beta_link_wiggle: Option<Vec<f64>>,
1073}
1074
1075pub struct GaussianLocationScaleFitResult {
1076    pub fit: BlockwiseTermFitResult,
1077    pub wiggle_knots: Option<Array1<f64>>,
1078    pub wiggle_degree: Option<usize>,
1079    pub beta_link_wiggle: Option<Vec<f64>>,
1080    /// Response standardization factor applied internally during fitting.
1081    ///
1082    /// The Gaussian location-scale path fits on `y / response_scale` so the
1083    /// fixed log-σ soft floor `LOGB_SIGMA_FLOOR = 0.01` is *operationally*
1084    /// scale-relative (1 % of the response spread) rather than absolute,
1085    /// keeping κ = dlogσ/dη ≈ 1 across the realistic σ range and informing the
1086    /// scale block like gamlss. The returned coefficient `blocks`, `beta`, and
1087    /// link-wiggle knots/coefficients are already mapped back to **raw response
1088    /// units** (the Location/Mean block scaled by `response_scale`, the Scale
1089    /// block intercept shifted by `+ln(response_scale)`), so downstream
1090    /// reconstruction `μ = X_mean·β` comes out in raw units with no further
1091    /// rescaling.
1092    ///
1093    /// The σ reconstruction, however, **must scale the floor too** to stay
1094    /// response-scale-equivariant (#884):
1095    ///
1096    /// ```text
1097    /// σ = response_scale·LOGB_SIGMA_FLOOR + exp(X_scale·β)
1098    ///   = response_scale·(LOGB_SIGMA_FLOOR + exp(η_internal)).
1099    /// ```
1100    ///
1101    /// The intercept shift carries only the `exp(η)` term; reconstructing with a
1102    /// raw `LOGB_SIGMA_FLOOR` instead of `response_scale·LOGB_SIGMA_FLOOR` leaves
1103    /// the non-equivariant residual `LOGB_SIGMA_FLOOR·(1 − response_scale)`.
1104    ///
1105    /// This field records the factor that was applied for transparency,
1106    /// covariance bookkeeping, and the equivariant σ-floor reconstruction; it is
1107    /// `1.0` when no standardization was needed (degenerate constant response).
1108    pub response_scale: f64,
1109}
1110
1111/// Exact coefficient-frame map for the frozen-basis binomial mean-wiggle
1112/// de-aliasing step.
1113///
1114/// The joint solver sees `[X, B - XA]` and returns coordinates
1115/// `(beta_mean_solver, beta_w)`. Saved prediction deliberately uses `[X, B]`,
1116/// so the reported coordinates are
1117///
1118/// ```text
1119/// beta_mean_saved = beta_mean_solver - A beta_w
1120/// beta_w_saved    = beta_w.
1121/// ```
1122///
1123/// This is one linear section with the cross-block lift
1124/// `M = [[I, -A], [0, I]]`. Keeping it as a [`gam_problem::Gauge`] makes the
1125/// same map authoritative for coefficients, covariance, and the active
1126/// geometry lineage used by saved-model ALO.
1127fn binomial_mean_wiggle_saved_frame_gauge(
1128    alias: &Array2<f64>,
1129    mean_width: usize,
1130    wiggle_width: usize,
1131) -> Result<gam_problem::Gauge, String> {
1132    if alias.dim() != (mean_width, wiggle_width) {
1133        return Err(format!(
1134            "binomial mean-wiggle de-alias map is {}x{}, expected {mean_width}x{wiggle_width}",
1135            alias.nrows(),
1136            alias.ncols(),
1137        ));
1138    }
1139    let total_width = mean_width
1140        .checked_add(wiggle_width)
1141        .ok_or_else(|| "binomial mean-wiggle coefficient dimension overflows usize".to_string())?;
1142    let mut transform = Array2::<f64>::eye(total_width);
1143    for row in 0..mean_width {
1144        for column in 0..wiggle_width {
1145            transform[[row, mean_width + column]] = -alias[[row, column]];
1146        }
1147    }
1148    let gauge = gam_problem::Gauge::from_t(
1149        transform,
1150        &[mean_width, wiggle_width],
1151        &[mean_width, wiggle_width],
1152    );
1153    gauge.validate().map_err(|reason| {
1154        format!("binomial mean-wiggle saved coefficient gauge is invalid: {reason}")
1155    })?;
1156    Ok(gauge)
1157}
1158
1159fn binomial_mean_wiggle_saved_geometry(
1160    geometry: &gam_solve::model_types::FitGeometry,
1161    saved_frame: &gam_problem::Gauge,
1162) -> Result<gam_solve::model_types::FitGeometry, String> {
1163    let mut saved_geometry = geometry.clone();
1164    saved_geometry.coefficient_gauge = geometry
1165        .coefficient_gauge
1166        .left_compose(saved_frame)
1167        .map_err(|reason| {
1168            format!(
1169                "binomial mean-wiggle active geometry cannot compose with its exact saved-result gauge: {reason}"
1170            )
1171        })?;
1172    Ok(saved_geometry)
1173}
1174
1175fn binomial_mean_wiggle_saved_covariance(
1176    covariance: &Array2<f64>,
1177    saved_frame: &gam_problem::Gauge,
1178    label: &str,
1179) -> Result<Array2<f64>, String> {
1180    let expected = saved_frame.reduced_total();
1181    if covariance.dim() != (expected, expected) {
1182        return Err(format!(
1183            "binomial mean-wiggle {label} is {}x{}; exact saved-result gauge requires {expected}x{expected} solver-frame coordinates",
1184            covariance.nrows(),
1185            covariance.ncols(),
1186        ));
1187    }
1188    if let Some(((row, column), value)) = covariance
1189        .indexed_iter()
1190        .find(|(_, value)| !value.is_finite())
1191    {
1192        return Err(format!(
1193            "binomial mean-wiggle {label} is non-finite at ({row}, {column}): {value}"
1194        ));
1195    }
1196    let saved = saved_frame.lift_covariance(covariance);
1197    if let Some(((row, column), value)) = saved.indexed_iter().find(|(_, value)| !value.is_finite())
1198    {
1199        return Err(format!(
1200            "binomial mean-wiggle saved-frame {label} is non-finite at ({row}, {column}): {value}"
1201        ));
1202    }
1203    Ok(saved)
1204}
1205
1206/// Atomically move a converged frozen-basis mean-wiggle fit from the solver's
1207/// residualized-design coordinates into the saved prediction coordinates.
1208///
1209/// The penalized Hessian remains in its exact active solver coordinates;
1210/// composing its coefficient gauge records how raw saved rows pull back into
1211/// that frame. Covariances, in contrast, push forward through the saved-frame
1212/// map. No dimension mismatch is ignorable: returning a partially transformed
1213/// fit would make point estimates and uncertainty describe different models.
1214fn finalize_binomial_mean_wiggle_saved_frame(
1215    fit: &mut UnifiedFitResult,
1216    alias: &Array2<f64>,
1217    mean_design: &Array2<f64>,
1218    mean_offset: &Array1<f64>,
1219) -> Result<(), String> {
1220    use gam_problem::BlockRole;
1221
1222    if fit.blocks.len() != 2
1223        || fit.blocks[0].role != BlockRole::Mean
1224        || fit.blocks[1].role != BlockRole::LinkWiggle
1225    {
1226        return Err(format!(
1227            "binomial mean-wiggle saved-frame finalization requires fitted blocks [Mean, LinkWiggle], got {:?}",
1228            fit.blocks
1229                .iter()
1230                .map(|block| block.role)
1231                .collect::<Vec<_>>()
1232        ));
1233    }
1234    if fit.block_states.len() != 2 {
1235        return Err(format!(
1236            "binomial mean-wiggle saved-frame finalization requires two fitted block states, got {}",
1237            fit.block_states.len(),
1238        ));
1239    }
1240    if mean_offset.len() != mean_design.nrows() {
1241        return Err(format!(
1242            "binomial mean-wiggle mean offset has {} rows, expected {}",
1243            mean_offset.len(),
1244            mean_design.nrows(),
1245        ));
1246    }
1247
1248    let mean_width = fit.blocks[0].beta.len();
1249    let wiggle_width = fit.blocks[1].beta.len();
1250    if mean_design.ncols() != mean_width {
1251        return Err(format!(
1252            "binomial mean-wiggle saved mean design has {} columns, expected fitted width {mean_width}",
1253            mean_design.ncols(),
1254        ));
1255    }
1256    for block_index in 0..2 {
1257        if fit.block_states[block_index].beta != fit.blocks[block_index].beta {
1258            return Err(format!(
1259                "binomial mean-wiggle fitted block {block_index} and block-state coefficients disagree before saved-frame finalization"
1260            ));
1261        }
1262    }
1263    let total_width = mean_width
1264        .checked_add(wiggle_width)
1265        .ok_or_else(|| "binomial mean-wiggle coefficient dimension overflows usize".to_string())?;
1266    if fit.beta.len() != total_width {
1267        return Err(format!(
1268            "binomial mean-wiggle flat coefficient vector has width {}, expected {total_width}",
1269            fit.beta.len(),
1270        ));
1271    }
1272    if fit.beta.slice(s![0..mean_width]) != fit.blocks[0].beta
1273        || fit.beta.slice(s![mean_width..total_width]) != fit.blocks[1].beta
1274    {
1275        return Err(
1276            "binomial mean-wiggle flat and block coefficient vectors disagree before saved-frame finalization"
1277                .to_string(),
1278        );
1279    }
1280
1281    let saved_frame = binomial_mean_wiggle_saved_frame_gauge(alias, mean_width, wiggle_width)?;
1282    let saved_blocks =
1283        saved_frame.lift_block_betas(&[fit.blocks[0].beta.clone(), fit.blocks[1].beta.clone()]);
1284    let saved_mean_eta = mean_design.dot(&saved_blocks[0]) + mean_offset;
1285    let mut saved_beta = Array1::<f64>::zeros(total_width);
1286    saved_beta
1287        .slice_mut(s![0..mean_width])
1288        .assign(&saved_blocks[0]);
1289    saved_beta
1290        .slice_mut(s![mean_width..total_width])
1291        .assign(&saved_blocks[1]);
1292
1293    let saved_conditional = fit
1294        .covariance_conditional
1295        .as_ref()
1296        .map(|covariance| {
1297            binomial_mean_wiggle_saved_covariance(
1298                covariance,
1299                &saved_frame,
1300                "conditional covariance",
1301            )
1302        })
1303        .transpose()?;
1304    let saved_corrected = fit
1305        .covariance_corrected
1306        .as_ref()
1307        .map(|covariance| {
1308            binomial_mean_wiggle_saved_covariance(covariance, &saved_frame, "corrected covariance")
1309        })
1310        .transpose()?;
1311    let saved_geometry = binomial_mean_wiggle_saved_geometry(
1312        fit.geometry.as_ref().ok_or_else(|| {
1313            "binomial mean-wiggle fit is missing its exact active geometry".to_string()
1314        })?,
1315        &saved_frame,
1316    )?;
1317
1318    let mut saved_inference = fit.inference.clone();
1319    if let Some(inference) = saved_inference.as_mut() {
1320        if inference.beta_covariance.is_none() && inference.beta_standard_errors.is_some() {
1321            return Err(
1322                "binomial mean-wiggle inference has conditional standard errors without their covariance"
1323                    .to_string(),
1324            );
1325        }
1326        if inference.beta_covariance_corrected.is_none()
1327            && inference.beta_standard_errors_corrected.is_some()
1328        {
1329            return Err(
1330                "binomial mean-wiggle inference has corrected standard errors without their covariance"
1331                    .to_string(),
1332            );
1333        }
1334        if let Some(covariance) = inference.beta_covariance.take() {
1335            let covariance = binomial_mean_wiggle_saved_covariance(
1336                covariance.as_array(),
1337                &saved_frame,
1338                "inference conditional covariance",
1339            )?;
1340            if inference.beta_standard_errors.is_some() {
1341                inference.beta_standard_errors = Some(
1342                    gam_problem::se_from_covariance(&covariance).map_err(|reason| {
1343                        format!(
1344                            "binomial mean-wiggle saved conditional standard errors are invalid: {reason}"
1345                        )
1346                    })?,
1347                );
1348            }
1349            inference.beta_covariance = Some(covariance.into());
1350        }
1351        if let Some(covariance) = inference.beta_covariance_corrected.take() {
1352            let covariance = binomial_mean_wiggle_saved_covariance(
1353                &covariance,
1354                &saved_frame,
1355                "inference corrected covariance",
1356            )?;
1357            if inference.beta_standard_errors_corrected.is_some() {
1358                inference.beta_standard_errors_corrected = Some(
1359                    gam_problem::se_from_covariance(&covariance).map_err(|reason| {
1360                        format!(
1361                            "binomial mean-wiggle saved corrected standard errors are invalid: {reason}"
1362                        )
1363                    })?,
1364                );
1365            }
1366            inference.beta_covariance_corrected = Some(covariance);
1367        }
1368        if let Some(covariance) = inference.beta_covariance_frequentist.take() {
1369            inference.beta_covariance_frequentist = Some(binomial_mean_wiggle_saved_covariance(
1370                &covariance,
1371                &saved_frame,
1372                "frequentist covariance",
1373            )?);
1374        }
1375        if let Some(correction) = inference.smoothing_correction.take() {
1376            inference.smoothing_correction = Some(binomial_mean_wiggle_saved_covariance(
1377                &correction,
1378                &saved_frame,
1379                "smoothing covariance correction",
1380            )?);
1381        }
1382    }
1383
1384    fit.blocks[0].beta = saved_blocks[0].clone();
1385    fit.blocks[1].beta = saved_blocks[1].clone();
1386    fit.block_states[0].beta = saved_blocks[0].clone();
1387    fit.block_states[0].eta = saved_mean_eta;
1388    fit.block_states[1].beta = saved_blocks[1].clone();
1389    fit.beta = saved_beta;
1390    fit.covariance_conditional = saved_conditional;
1391    fit.covariance_corrected = saved_corrected;
1392    fit.geometry = Some(saved_geometry);
1393    fit.inference = saved_inference;
1394    Ok(())
1395}
1396
1397#[cfg(test)]
1398mod binomial_mean_wiggle_saved_frame_tests {
1399    use super::*;
1400    use ndarray::array;
1401
1402    #[test]
1403    fn cross_block_dealias_composes_non_square_geometry_and_pushes_covariance() {
1404        let alias = array![[2.0], [-0.5]];
1405        let saved_frame = binomial_mean_wiggle_saved_frame_gauge(&alias, 2, 1)
1406            .expect("valid cross-block de-alias map");
1407
1408        // The canonical solver retained one of two Mean directions plus the
1409        // LinkWiggle direction: active(2) -> solver raw(3) is rectangular.
1410        let active_to_solver = gam_problem::Gauge::from_t(
1411            array![[1.0, 0.0], [0.0, 0.0], [0.0, 1.0]],
1412            &[2, 1],
1413            &[1, 1],
1414        );
1415        let active_hessian = array![[7.0, 1.5], [1.5, 4.0]];
1416        let geometry = gam_solve::model_types::FitGeometry {
1417            coefficient_gauge: active_to_solver,
1418            penalized_hessian: active_hessian.clone().into(),
1419            constrained_posterior: None,
1420            working: None,
1421        };
1422        let saved_geometry = binomial_mean_wiggle_saved_geometry(&geometry, &saved_frame)
1423            .expect("non-square active geometry composes through saved frame");
1424
1425        assert_eq!(
1426            saved_geometry.coefficient_gauge.t_full,
1427            array![[1.0, -2.0], [0.0, 0.5], [0.0, 1.0]],
1428        );
1429        assert_eq!(
1430            saved_geometry.penalized_hessian.as_array(),
1431            &active_hessian,
1432            "precision stays in the canonical active frame",
1433        );
1434
1435        let solver_covariance = Array2::<f64>::eye(3);
1436        let saved_covariance = binomial_mean_wiggle_saved_covariance(
1437            &solver_covariance,
1438            &saved_frame,
1439            "test covariance",
1440        )
1441        .expect("covariance pushes into saved frame");
1442        assert_eq!(
1443            saved_covariance,
1444            array![[5.0, -1.0, -2.0], [-1.0, 1.25, 0.5], [-2.0, 0.5, 1.0]],
1445            "the -A cross block must alter both Mean variance and Mean/Wiggle covariance",
1446        );
1447    }
1448}
1449
1450/// Fit the binomial mean link-wiggle model. The observation-space de-aliasing
1451/// preserves the standard I-spline coefficient coordinate, which is returned
1452/// for the saved-model predict runtime.
1453pub(crate) fn fit_binomial_mean_wiggle(
1454    spec: BinomialMeanWiggleSpec,
1455    options: &BlockwiseFitOptions,
1456) -> Result<(UnifiedFitResult, Option<Vec<f64>>, Option<Vec<f64>>), String> {
1457    let n = spec.y.len();
1458    validate_len_match("weights vs y", n, spec.weights.len())?;
1459    validateweights(&spec.weights, "fit_binomial_mean_wiggle")?;
1460    validate_binomial_response(&spec.y, "fit_binomial_mean_wiggle")?;
1461    validate_blockrows("eta", n, &spec.eta_block)?;
1462    validate_blockrows("wiggle", n, &spec.wiggle_block)?;
1463    if matches!(
1464        spec.link_kind,
1465        InverseLink::Standard(StandardLink::Identity)
1466    ) {
1467        return Err(GamlssError::UnsupportedConfiguration {
1468            reason: "fit_binomial_mean_wiggle does not support identity link".to_string(),
1469        }
1470        .into());
1471    }
1472    gam_terms::inference::formula_dsl::require_binomial_inverse_link_supports_joint_wiggle(
1473        &spec.link_kind,
1474        "fit_binomial_mean_wiggle",
1475    )?;
1476    if spec.wiggle_degree < 2 {
1477        return Err(GamlssError::ConstraintViolation {
1478            reason: format!(
1479                "fit_binomial_mean_wiggle: wiggle_degree must be >= 2, got {}",
1480                spec.wiggle_degree
1481            ),
1482        }
1483        .into());
1484    }
1485    let minimum_knots = minimum_monotone_wiggle_knot_count(spec.wiggle_degree)?;
1486    if spec.wiggle_knots.len() < minimum_knots {
1487        return Err(GamlssError::DimensionMismatch { reason: format!(
1488            "fit_binomial_mean_wiggle: wiggle_knots length {} is too short for degree {} (need at least {})",
1489            spec.wiggle_knots.len(),
1490            spec.wiggle_degree,
1491            minimum_knots
1492        ) }.into());
1493    }
1494
1495    // ----- Frozen-basis Gauss-Newton link-warp fit (#1596) -----
1496    //
1497    // The warp basis `B(η)` is frozen at the current index `η̂` so that
1498    // `q = η + B(η̂)·β_w` is linear in `(β_η, β_w)` (`∂q/∂η = 1`). To keep the
1499    // mean block `X` full and identifiable we fit the warp through the
1500    // observation-space residualized design `B⊥ = (I - P_X)B(η̂)`. We re-freeze
1501    // at the refit `η̂` until the caller's outer convergence policy certifies the
1502    // fixed point.
1503    let x_dense: Array2<f64> = spec.eta_block.design.to_dense();
1504    let (pilot_beta, pilot_eta): (Array1<f64>, Array1<f64>) = {
1505        let pilot_beta = spec.eta_block.initial_beta.clone().ok_or_else(|| {
1506            "fit_binomial_mean_wiggle: eta block carries no pilot β to seed the \
1507             frozen-basis warp index"
1508                .to_string()
1509        })?;
1510        if x_dense.ncols() != pilot_beta.len() {
1511            return Err(GamlssError::DimensionMismatch {
1512                reason: format!(
1513                    "fit_binomial_mean_wiggle: eta design has {} columns but pilot β has {} \
1514                     coefficients",
1515                    x_dense.ncols(),
1516                    pilot_beta.len()
1517                ),
1518            }
1519            .into());
1520        }
1521        let mut eta = x_dense.dot(&pilot_beta);
1522        eta += &spec.eta_block.offset;
1523        (pilot_beta, eta)
1524    };
1525
1526    // Original (full-width) warp penalties / nullspace metadata, captured before
1527    // `spec.wiggle_block` is consumed. The residualized block keeps the same
1528    // coefficient coordinate and therefore the same penalties.
1529    let wiggle_penalties_full = spec.wiggle_block.penalties.clone();
1530    let wiggle_nullspace_dims = spec.wiggle_block.nullspace_dims.clone();
1531    if !wiggle_nullspace_dims.is_empty()
1532        && wiggle_nullspace_dims.len() != wiggle_penalties_full.len()
1533    {
1534        return Err(GamlssError::DimensionMismatch {
1535            reason: format!(
1536                "fit_binomial_mean_wiggle: wiggle block has {} penalties but {} nullspace dimensions",
1537                wiggle_penalties_full.len(),
1538                wiggle_nullspace_dims.len()
1539            ),
1540        }
1541        .into());
1542    }
1543    let wiggle_log_lambdas = spec.wiggle_block.initial_log_lambdas.clone();
1544    let wiggle_beta_initial = spec.wiggle_block.initial_beta.clone();
1545    let eta_block_input = spec.eta_block.clone();
1546
1547    let family = BinomialMeanWiggleFamily {
1548        y: spec.y,
1549        weights: spec.weights,
1550        link_kind: spec.link_kind,
1551        wiggle_knots: spec.wiggle_knots,
1552        wiggle_degree: spec.wiggle_degree,
1553        policy: gam_runtime::resource::ResourcePolicy::default_library(),
1554        frozen_warp_design: None,
1555    };
1556
1557    // Build the de-aliased warp block at a frozen index.  The identifiable
1558    // warp is the part of `B(η̂)` outside the mean column space:
1559    //
1560    //     B⊥ = (I - P_X) B = B - X A,     A = (XᵀX)^+ XᵀB.
1561    //
1562    // The previous implementation used `Z = null(XᵀB)` and fitted `B Z`.
1563    // That is too strong: when the warp basis has no coefficient combination
1564    // exactly orthogonal to `X` (for example a two-column flexible link beside
1565    // an intercept+slope mean), it drops every warp coefficient even though
1566    // the nonlinear columns of `B(η̂)` have a nonzero residual after projection
1567    // onto `X`.  Residualizing in observation space removes only the truly
1568    // mean-aliased component and leaves the curved, identifiable link-shape
1569    // signal available to the joint solve.
1570    //
1571    // The returned `A` is used after fitting: because the inner problem used
1572    // `Xβ + (B - XA)β_w`, while prediction reconstructs the saved warp as
1573    // `Xβ_saved + Bβ_w`, we save `β_saved = β - Aβ_w`.
1574    let build_dealiased = |frozen: &Array1<f64>,
1575                           beta_hint: Option<&Array1<f64>>,
1576                           log_lambda_hint: Option<&Array1<f64>>|
1577     -> Result<
1578        (
1579            ParameterBlockInput,
1580            Array2<f64>,
1581            std::sync::Arc<Array2<f64>>,
1582        ),
1583        String,
1584    > {
1585        use faer::Side;
1586        use gam_linalg::faer_ndarray::FaerEigh;
1587
1588        let b_full = family.wiggle_design(frozen.view())?;
1589        let xtx = x_dense.t().dot(&x_dense);
1590        let xtb = x_dense.t().dot(&b_full);
1591        let (evals, evecs) = xtx
1592            .eigh(Side::Lower)
1593            .map_err(|e| format!("frozen-basis warp de-aliasing mean QR failed: {e}"))?;
1594        let max_eval = evals.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
1595        let cutoff = 1.0e3 * f64::EPSILON * (xtx.nrows().max(1) as f64) * max_eval.max(1.0);
1596        let mut alias = Array2::<f64>::zeros((x_dense.ncols(), b_full.ncols()));
1597        for k in 0..evals.len() {
1598            let lam = evals[k];
1599            if !lam.is_finite() || lam.abs() <= cutoff {
1600                continue;
1601            }
1602            let uk = evecs.column(k);
1603            let uk_xtb = uk.t().dot(&xtb);
1604            for i in 0..alias.nrows() {
1605                for j in 0..alias.ncols() {
1606                    alias[[i, j]] += uk[i] * uk_xtb[j] / lam;
1607                }
1608            }
1609        }
1610        let bda = &b_full - &x_dense.dot(&alias);
1611        let max_b = b_full.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
1612        let max_resid = bda.iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
1613        let resid_tol =
1614            1.0e3 * f64::EPSILON * (bda.nrows().max(bda.ncols()).max(1) as f64) * max_b.max(1.0);
1615        if max_resid <= resid_tol {
1616            return Err("frozen-basis warp de-aliasing left no identifiable warp \
1617                        direction (the mean block already spans the warp in \
1618                        observation space)"
1619                .to_string());
1620        }
1621        let penalties: Vec<crate::model_types::PenaltySpec> = wiggle_penalties_full
1622            .iter()
1623            .map(|p| {
1624                let s = penalty_spec_to_dense(p, b_full.ncols())?;
1625                Ok(crate::model_types::PenaltySpec::Dense(s))
1626            })
1627            .collect::<Result<_, String>>()?;
1628        let q = bda.ncols();
1629        let initial_beta = match beta_hint {
1630            Some(beta) if beta.len() == q => Some(beta.clone()),
1631            Some(beta) => {
1632                return Err(format!(
1633                    "frozen-basis warp warm start has {} coefficients but the realized basis has {q}",
1634                    beta.len()
1635                ));
1636            }
1637            None => Some(Array1::zeros(q)),
1638        };
1639        let block = ParameterBlockInput {
1640            design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(bda.clone())),
1641            offset: Array1::zeros(frozen.len()),
1642            penalties,
1643            nullspace_dims: wiggle_nullspace_dims.clone(),
1644            initial_log_lambdas: log_lambda_hint
1645                .cloned()
1646                .or_else(|| wiggle_log_lambdas.clone()),
1647            initial_beta,
1648        };
1649        Ok((block, alias, std::sync::Arc::new(bda)))
1650    };
1651
1652    // Outer Gauss-Newton / backfitting loop over the frozen warp index. The
1653    // smoothing parameters remain continuously REML/LAML-optimized at every
1654    // step. A fit is returned only after the frozen index is a certified fixed
1655    // point; exhaustion returns non-convergence evidence instead of minting a
1656    // fit from the last iterate. The exact coefficient source used to build the
1657    // accepted frozen basis is retained for prediction (#2141).
1658    if options.outer_max_iter == 0 || !options.outer_tol.is_finite() || options.outer_tol <= 0.0 {
1659        return Err(GamlssError::InvalidInput {
1660            reason: format!(
1661                "fit_binomial_mean_wiggle requires positive outer convergence policy; outer_max_iter={}, outer_tol={}",
1662                options.outer_max_iter, options.outer_tol
1663            ),
1664        }
1665        .into());
1666    }
1667    let mut frozen_source_beta = pilot_beta;
1668    let mut frozen_eta = pilot_eta;
1669    let mut eta_block_warm = eta_block_input.clone();
1670    let mut wiggle_beta_warm = wiggle_beta_initial;
1671    let mut wiggle_log_lambda_warm = wiggle_log_lambdas.clone();
1672    let mut converged: Option<(UnifiedFitResult, Array2<f64>, Array1<f64>)> = None;
1673    let mut last_delta = f64::INFINITY;
1674    let mut last_scale = 1.0_f64;
1675    for _outer in 0..options.outer_max_iter {
1676        let (wiggle_block, alias, bda) = build_dealiased(
1677            &frozen_eta,
1678            wiggle_beta_warm.as_ref(),
1679            wiggle_log_lambda_warm.as_ref(),
1680        )?;
1681        let eta_penalty_count = eta_block_warm.penalties.len();
1682        let wiggle_penalty_count = wiggle_block.penalties.len();
1683        let blocks = vec![
1684            eta_block_warm.clone().intospec("eta")?,
1685            wiggle_block.intospec("wiggle")?,
1686        ];
1687        let mut fam = family.clone();
1688        fam.frozen_warp_design = Some(bda);
1689        let fit = fit_custom_family(&fam, &blocks, options).map_err(|e| e.to_string())?;
1690        let mean_state = fit
1691            .block_states
1692            .get(BinomialMeanWiggleFamily::BLOCK_ETA)
1693            .ok_or_else(|| {
1694                "fit_binomial_mean_wiggle: frozen-basis refit did not expose a fitted eta block"
1695                    .to_string()
1696            })?;
1697        if mean_state.eta.len() != frozen_eta.len()
1698            || mean_state.beta.len() != frozen_source_beta.len()
1699        {
1700            return Err(GamlssError::DimensionMismatch {
1701                reason: "fit_binomial_mean_wiggle: frozen-basis refit returned an incompatible eta block"
1702                    .to_string(),
1703            }
1704            .into());
1705        }
1706        let new_eta = mean_state.eta.clone();
1707        let new_source_beta = mean_state.beta.clone();
1708        let new_wiggle_beta = fit
1709            .block_states
1710            .get(BinomialMeanWiggleFamily::BLOCK_WIGGLE)
1711            .map(|state| state.beta.clone())
1712            .ok_or_else(|| {
1713                "fit_binomial_mean_wiggle: frozen-basis refit did not expose a fitted wiggle block"
1714                    .to_string()
1715            })?;
1716        last_scale = frozen_eta
1717            .iter()
1718            .chain(new_eta.iter())
1719            .map(|value| value.abs())
1720            .fold(1.0_f64, f64::max);
1721        last_delta = new_eta
1722            .iter()
1723            .zip(frozen_eta.iter())
1724            .map(|(a, b)| (a - b).abs())
1725            .fold(0.0_f64, f64::max);
1726        if last_delta <= options.outer_tol * last_scale {
1727            converged = Some((fit, alias, frozen_source_beta));
1728            break;
1729        }
1730
1731        let expected_log_lambdas = eta_penalty_count + wiggle_penalty_count;
1732        if fit.log_lambdas.len() != expected_log_lambdas {
1733            return Err(GamlssError::DimensionMismatch {
1734                reason: format!(
1735                    "fit_binomial_mean_wiggle: refit returned {} log-lambdas for {expected_log_lambdas} penalties",
1736                    fit.log_lambdas.len()
1737                ),
1738            }
1739            .into());
1740        }
1741        eta_block_warm.initial_beta = Some(new_source_beta.clone());
1742        eta_block_warm.initial_log_lambdas =
1743            Some(fit.log_lambdas.slice(s![0..eta_penalty_count]).to_owned());
1744        wiggle_beta_warm = Some(new_wiggle_beta);
1745        wiggle_log_lambda_warm = Some(
1746            fit.log_lambdas
1747                .slice(s![eta_penalty_count..expected_log_lambdas])
1748                .to_owned(),
1749        );
1750        frozen_source_beta = new_source_beta;
1751        frozen_eta = new_eta;
1752    }
1753    let (mut fit, last_alias, frozen_source_beta) = converged.ok_or_else(|| {
1754        GamlssError::NumericalFailure {
1755            reason: format!(
1756                "fit_binomial_mean_wiggle frozen-index fixed point did not converge in {} outer iterations: delta={last_delta:.3e}, scale={last_scale:.3e}, tolerance={:.3e}",
1757                options.outer_max_iter,
1758                options.outer_tol * last_scale,
1759            ),
1760        }
1761        .to_string()
1762    })?;
1763    // Capture the mean coefficients whose linear predictor is the *frozen index*
1764    // `η̂` the warp basis `B(η̂)` was pinned at (#2141). The reported deviance is
1765    // evaluated with `q = X·β_saved + B(η̂)·β_w`, so `predict` must re-evaluate the
1766    // warp basis at `X·β_frozen_source` (= η̂), NOT at the de-aliased base
1767    // predictor `X·β_saved`. On the failing data those differ by the identifiable
1768    // de-alias projection `X·A·β_w`, so predict-at-`β_saved` reconstructs a
1769    // *different* link than the fit used. We persist the shift
1770    // `s = β_frozen_source − β_saved` (a mean-coordinate vector) so predict can form the
1771    // frozen index `X·(β_saved + s) = η̂` and reproduce the fitted `q` exactly.
1772    // The solver coefficient is already the standard I-spline coefficient:
1773    // observation-space residualization changed the design, not its coefficient
1774    // chart. The family imposes β_w ≥ 0 during the continuously optimized
1775    // constrained REML/LAML fit. Since B' is an M-spline basis with non-negative
1776    // values, dq/dη = 1 + B'(η)·β_w ≥ 1 for every η, including between
1777    // knots; no post-fit sampling or smoothing-parameter ladder is needed.
1778    let saved_warp_beta = fit
1779        .block_states
1780        .get(BinomialMeanWiggleFamily::BLOCK_WIGGLE)
1781        .map(|state| state.beta.to_vec())
1782        .ok_or_else(|| {
1783            "fit_binomial_mean_wiggle: converged fit is missing its LinkWiggle block state"
1784                .to_string()
1785        })?;
1786    validate_monotone_wiggle_beta_nonnegative(
1787        &saved_warp_beta,
1788        "fit_binomial_mean_wiggle saved warp",
1789    )?;
1790    finalize_binomial_mean_wiggle_saved_frame(
1791        &mut fit,
1792        &last_alias,
1793        &x_dense,
1794        &eta_block_input.offset,
1795    )?;
1796    // The frozen-index shift `s = β_frozen_source − β_saved` for the predict
1797    // runtime (#2141). `β_saved` is the just-de-aliased mean block; adding
1798    // `X·s` to the predict base predictor recovers the frozen warp index `η̂`.
1799    // Only meaningful when a warp actually engaged (`saved_warp_beta` present).
1800    let saved_mean_state = fit
1801        .block_states
1802        .get(BinomialMeanWiggleFamily::BLOCK_ETA)
1803        .ok_or_else(|| {
1804            "fit_binomial_mean_wiggle: finalized fit is missing its Mean block state".to_string()
1805        })?;
1806    if frozen_source_beta.len() != saved_mean_state.beta.len() {
1807        return Err(format!(
1808            "fit_binomial_mean_wiggle: frozen-index source has {} coefficients, but saved Mean block has {}",
1809            frozen_source_beta.len(),
1810            saved_mean_state.beta.len(),
1811        ));
1812    }
1813    let saved_index_shift = Some((&frozen_source_beta - &saved_mean_state.beta).to_vec());
1814    Ok((fit, Some(saved_warp_beta), saved_index_shift))
1815}
1816
1817/// Densify a wiggle-block penalty spec to its full `p×p` matrix for the
1818/// observation-space de-aliasing path (#1596). The link-warp block carries only
1819/// `Dense`/`DenseWithMean` difference (and optional ridge) penalties.
1820fn penalty_spec_to_dense(
1821    spec: &crate::model_types::PenaltySpec,
1822    p: usize,
1823) -> Result<Array2<f64>, String> {
1824    use crate::model_types::PenaltySpec;
1825    match spec {
1826        PenaltySpec::Dense(m) | PenaltySpec::DenseWithMean { matrix: m, .. } => {
1827            if m.nrows() != p || m.ncols() != p {
1828                return Err(format!(
1829                    "frozen-basis warp penalty must be {p}x{p}, got {}x{}",
1830                    m.nrows(),
1831                    m.ncols()
1832                ));
1833            }
1834            Ok(m.clone())
1835        }
1836        PenaltySpec::Block {
1837            local, col_range, ..
1838        } => {
1839            let mut full = Array2::<f64>::zeros((p, p));
1840            if col_range.end > p || local.nrows() != col_range.len() {
1841                return Err("frozen-basis warp penalty block range out of bounds".to_string());
1842            }
1843            full.slice_mut(s![col_range.clone(), col_range.clone()])
1844                .assign(local);
1845            Ok(full)
1846        }
1847    }
1848}
1849
1850pub(crate) trait LocationScaleFamilyBuilder {
1851    type Family: CustomFamily + Clone + Send + Sync + 'static;
1852
1853    fn meanspec(&self) -> &TermCollectionSpec;
1854    fn noisespec(&self) -> &TermCollectionSpec;
1855
1856    fn build_blocks(
1857        &self,
1858        theta: &Array1<f64>,
1859        mean_design: &TermCollectionDesign,
1860        noise_design: &TermCollectionDesign,
1861        mean_beta_hint: Option<Array1<f64>>,
1862        noise_beta_hint: Option<Array1<f64>>,
1863    ) -> Result<Vec<ParameterBlockSpec>, String>;
1864
1865    fn build_family(
1866        &self,
1867        mean_design: &TermCollectionDesign,
1868        noise_design: &TermCollectionDesign,
1869    ) -> Self::Family;
1870
1871    fn extract_primary_betas(
1872        &self,
1873        fit: &UnifiedFitResult,
1874    ) -> Result<(Array1<f64>, Array1<f64>), String>;
1875
1876    fn mean_penalty_count(&self, mean_design: &TermCollectionDesign) -> usize {
1877        mean_design.penalties.len()
1878    }
1879
1880    fn noise_penalty_count(&self, noise_design: &TermCollectionDesign) -> usize {
1881        noise_design.penalties.len()
1882    }
1883
1884    fn exact_spatial_joint_supported(&self) -> bool {
1885        false
1886    }
1887
1888    fn require_exact_spatial_joint(&self) -> bool {
1889        false
1890    }
1891
1892    fn exact_spatial_seed_risk_profile(&self) -> crate::seeding::SeedRiskProfile {
1893        crate::seeding::SeedRiskProfile::GeneralizedLinear
1894    }
1895
1896    fn extra_rho0(&self) -> Result<Array1<f64>, String> {
1897        Ok(Array1::zeros(0))
1898    }
1899
1900    fn build_psiderivative_blocks(
1901        &self,
1902        arr: ndarray::ArrayView2<'_, f64>,
1903        term_spec: &TermCollectionSpec,
1904        term_spec2: &TermCollectionSpec,
1905        term_design: &TermCollectionDesign,
1906        term_design2: &TermCollectionDesign,
1907    ) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String>;
1908}
1909
1910pub(crate) fn fit_location_scale_terms<B: LocationScaleFamilyBuilder>(
1911    data: ndarray::ArrayView2<'_, f64>,
1912    builder: B,
1913    options: &BlockwiseFitOptions,
1914    kappa_options: &SpatialLengthScaleOptimizationOptions,
1915) -> Result<BlockwiseTermFitResult, String> {
1916    // Large-n location-scale fits keep the caller's explicit Hessian request.
1917    // The unified REML evaluator chooses a dense or matrix-free exact
1918    // representation from the realized (n, p, K) work model, so there is no
1919    // large-scale downgrade to BFGS here.
1920
1921    let mut mean_beta_hint: Option<Array1<f64>> = None;
1922    let mut noise_beta_hint: Option<Array1<f64>> = None;
1923    let extra_rho0 = builder.extra_rho0()?;
1924
1925    let mean_boot_design =
1926        build_term_collection_design(data, builder.meanspec()).map_err(|e| e.to_string())?;
1927    let noise_boot_design =
1928        build_term_collection_design(data, builder.noisespec()).map_err(|e| e.to_string())?;
1929    let mean_bootspec = freeze_term_collection_from_design(builder.meanspec(), &mean_boot_design)
1930        .map_err(|e| e.to_string())?;
1931    let noise_bootspec =
1932        freeze_term_collection_from_design(builder.noisespec(), &noise_boot_design)
1933            .map_err(|e| e.to_string())?;
1934
1935    let require_exact_spatial_joint = builder.require_exact_spatial_joint();
1936    let analytic_joint_derivatives_check = if builder.exact_spatial_joint_supported() {
1937        builder
1938            .build_psiderivative_blocks(
1939                data,
1940                &mean_bootspec,
1941                &noise_bootspec,
1942                &mean_boot_design,
1943                &noise_boot_design,
1944            )
1945            .map(|_| ())
1946    } else {
1947        Err(
1948            "analytic spatial psi derivatives are unavailable for this location-scale family"
1949                .to_string(),
1950        )
1951    };
1952    let analytic_joint_derivatives_available = analytic_joint_derivatives_check.is_ok();
1953    if require_exact_spatial_joint {
1954        analytic_joint_derivatives_check.map_err(|err| {
1955            format!("exact two-block spatial path requires analytic psi derivatives: {err}")
1956        })?;
1957    }
1958    let mean_penalty_count = builder.mean_penalty_count(&mean_boot_design);
1959    let noise_penalty_count = builder.noise_penalty_count(&noise_boot_design);
1960
1961    // Honor an explicit user-supplied `length_scale=X` on every spatial term
1962    // in both the mean and noise blocks: when every term is κ-locked (no
1963    // anisotropy, no per-axis ψ contrasts), the joint-spatial outer optimizer
1964    // has nothing to optimize. Routing through it anyway wraps the full
1965    // two-block coefficient solve inside an unnecessary outer loop where
1966    // each evaluation runs the inner Newton from scratch. This is the same
1967    // short-circuit the Bernoulli marginal-slope entry point performs at
1968    // bernoulli_marginal_slope.rs:16432-16442; mirroring it here makes the
1969    // GAMLSS path skip straight to the `(!enabled || log_kappa_dim == 0)`
1970    // fast path in `optimize_spatial_length_scale_exact_joint`.
1971    let mut effective_kappa_options = kappa_options.clone();
1972    if effective_kappa_options.enabled
1973        && gam_terms::smooth::all_spatial_terms_kappa_fixed(&mean_bootspec)
1974        && gam_terms::smooth::all_spatial_terms_kappa_fixed(&noise_bootspec)
1975    {
1976        log::info!(
1977            "[GAMLSS spatial] disabling κ/ψ optimization: every spatial term in \
1978             both blocks has an explicit length_scale and no anisotropy; \
1979             user-supplied kernel scale is fixed"
1980        );
1981        effective_kappa_options.enabled = false;
1982    }
1983    let kappa_options: &SpatialLengthScaleOptimizationOptions = &effective_kappa_options;
1984
1985    // Macro to invoke the exact-joint spatial optimizer with shared closures.
1986    // The exact path evaluates the full profiled/Laplace objective over
1987    // theta = [rho, psi] with the real joint Hessian required by NewtonTR/ARC.
1988    macro_rules! run_exact_joint_spatial {
1989        () => {{
1990            let joint_setup = build_two_block_exact_joint_setup(
1991                data,
1992                builder.meanspec(),
1993                builder.noisespec(),
1994                mean_penalty_count,
1995                noise_penalty_count,
1996                extra_rho0.as_slice().unwrap_or(&[]),
1997                None,
1998                kappa_options,
1999            )
2000            .map_err(|error| error.to_string())?;
2001            let mean_terms = spatial_length_scale_term_indices(builder.meanspec());
2002            let noise_terms = spatial_length_scale_term_indices(builder.noisespec());
2003            let mean_beta_hint_cell = std::cell::RefCell::new(mean_beta_hint.clone());
2004            let noise_beta_hint_cell = std::cell::RefCell::new(noise_beta_hint.clone());
2005            let hyper_warm_start_cell =
2006                std::cell::RefCell::new(None::<CustomFamilyWarmStart>);
2007            // Two-block GAMLSS/location-scale joint likelihoods have a
2008            // β-dependent cross-block Hessian (the (μ,log σ) / (t,log σ)
2009            // off-diagonal blocks involve residual/response scalars that
2010            // shift when β moves). The Wood-Fasiolo structural property
2011            // `H^{-1/2} B_k H^{-1/2} ≽ 0` plus parameter-independent
2012            // nullspace — the mathematical basis for EFS convergence —
2013            // fails here, so EFS/HybridEFS must be excluded at plan time
2014            // rather than retried as a silent first attempt that stalls
2015            // for hundreds of seconds before the runner falls back.
2016            let gamlss_disable_fixed_point = true;
2017            let outer_policy = {
2018                // GAMLSS spatial path: psi_dim = log_kappa_dim + auxiliary_dim,
2019                // matching the (theta_dim - rho_dim) decomposition the
2020                // optimizer uses internally. Build realized ParameterBlockSpecs
2021                // at the seed rho so the family's own cost model — which
2022                // multiplies coefficient-gradient / coefficient-Hessian
2023                // per-row cost by the joint outer-coordinate dimension and
2024                // total p — produces honest `predicted_*_work` estimates.
2025                // Previously this fed `predicted_*_work: 0` to the planner,
2026                // which then ungated dense outer Hessian work that costs
2027                // hundreds of seconds per eval at large scale (see
2028                // `OuterDerivativePolicy::OUTER_HESSIAN_WORK_BUDGET`).
2029                let theta_seed = joint_setup.theta0();
2030                let rho_dim = joint_setup.rho_dim();
2031                let psi_dim = theta_seed.len() - rho_dim;
2032                let rho_seed = theta_seed.slice(s![..rho_dim]).to_owned();
2033                let policy_blocks_res = builder.build_blocks(
2034                    &rho_seed,
2035                    &mean_boot_design,
2036                    &noise_boot_design,
2037                    mean_beta_hint_cell.borrow().clone(),
2038                    noise_beta_hint_cell.borrow().clone(),
2039                );
2040                let mut policy = match policy_blocks_res {
2041                    Ok(policy_blocks) => {
2042                        let policy_family =
2043                            builder.build_family(&mean_boot_design, &noise_boot_design);
2044                        crate::custom_family::CustomFamily::outer_derivative_policy(
2045                            &policy_family,
2046                            &policy_blocks,
2047                            psi_dim,
2048                            options,
2049                        )
2050                    }
2051                    Err(err) => {
2052                        // Block construction at the seed should not fail for
2053                        // any in-tree family, but if it does, fall back to a
2054                        // policy that names the capability honestly and
2055                        // declines to predict cost. Setting work to
2056                        // `u128::MAX` routes the planner through gradient-only
2057                        // BFGS (the universal Hessian-work budget is
2058                        // saturating, so a sentinel is fine here).
2059                        log::warn!(
2060                            "[GAMLSS spatial] failed to realize policy blocks at seed rho ({err}); \
2061                             routing outer optimizer through gradient-only BFGS"
2062                        );
2063                        let capability = if analytic_joint_derivatives_available {
2064                            crate::custom_family::ExactOuterDerivativeOrder::Second
2065                        } else {
2066                            crate::custom_family::ExactOuterDerivativeOrder::First
2067                        };
2068                        crate::custom_family::OuterDerivativePolicy {
2069                            capability,
2070                            predicted_gradient_work: u128::MAX,
2071                            predicted_hessian_work: u128::MAX,
2072                            // No GAMLSS family today overrides its
2073                            // outer-only `_with_options` hooks to consume
2074                            // `outer_score_subsample`; staged-κ would
2075                            // build pilot masks the family then ignores.
2076                            subsample_capable: false,
2077                        }
2078                    }
2079                };
2080                if !analytic_joint_derivatives_available {
2081                    // Capability must not exceed what the analytic derivatives
2082                    // path can supply — the macro's hyper evaluator returns
2083                    // an error otherwise.
2084                    policy.capability =
2085                        crate::custom_family::ExactOuterDerivativeOrder::First;
2086                }
2087                policy
2088            };
2089            optimize_spatial_length_scale_exact_joint(
2090                data,
2091                &[builder.meanspec().clone(), builder.noisespec().clone()],
2092                &[mean_terms, noise_terms],
2093                kappa_options,
2094                &joint_setup,
2095                builder.exact_spatial_seed_risk_profile(),
2096                analytic_joint_derivatives_available,
2097                analytic_joint_derivatives_available,
2098                gamlss_disable_fixed_point,
2099                None,
2100                outer_policy,
2101                |theta,
2102                 specs: &[TermCollectionSpec],
2103                 designs: &[TermCollectionDesign],
2104                 provenance| {
2105                    assert_eq!(
2106                        specs.len(),
2107                        2,
2108                        "joint spatial closure expects exactly two block specs (mean, noise); got {}",
2109                        specs.len(),
2110                    );
2111                    assert_eq!(
2112                        designs.len(),
2113                        2,
2114                        "joint spatial closure expects exactly two block designs (mean, noise); got {}",
2115                        designs.len(),
2116                    );
2117                    let rho = theta.slice(s![..joint_setup.rho_dim()]).to_owned();
2118                    let fit = {
2119                        let blocks = builder.build_blocks(
2120                            &rho,
2121                            &designs[0],
2122                            &designs[1],
2123                            mean_beta_hint_cell.borrow().clone(),
2124                            noise_beta_hint_cell.borrow().clone(),
2125                        )?;
2126                        if mean_beta_hint_cell.borrow().is_none()
2127                            && let Some(beta) = blocks.first().and_then(|block| block.initial_beta.clone())
2128                        {
2129                            *mean_beta_hint_cell.borrow_mut() = Some(beta);
2130                        }
2131                        if noise_beta_hint_cell.borrow().is_none()
2132                            && let Some(beta) =
2133                                blocks.get(1).and_then(|block| block.initial_beta.clone())
2134                        {
2135                            *noise_beta_hint_cell.borrow_mut() = Some(beta);
2136                        }
2137                        let family = builder.build_family(&designs[0], &designs[1]);
2138                        // Branch on whether the κ optimizer drives rho.
2139                        //
2140                        // * `log_kappa_dim() > 0 && kappa_options.enabled` ⇒
2141                        //   the outer (ρ, ψ) optimizer is active and
2142                        //   passes each candidate ρ to this closure;
2143                        //   the inner fit must hold log-lambdas fixed
2144                        //   at the supplied ρ so the outer derivative
2145                        //   has a well-defined directional gradient.
2146                        //
2147                        // * Otherwise (κ disabled via the locked-κ
2148                        //   short-circuit, or no spatial terms at all)
2149                        //   the fast path in
2150                        //   `optimize_spatial_length_scale_exact_joint`
2151                        //   calls this closure exactly once at
2152                        //   `theta = theta0`; ρ must still be optimized
2153                        //   from data because the user never pinned it.
2154                        //   `fit_custom_family` performs the joint
2155                        //   ρ + coefficient REML fit at the user's
2156                        //   (now-fixed) kernel scale, which is the
2157                        //   intended behaviour when `length_scale=…` is
2158                        //   set on every spatial term.
2159                        if joint_setup.log_kappa_dim() > 0 && kappa_options.enabled {
2160                            let (certified_outer, mode) = match provenance {
2161                                SpatialFitProvenance::Certified { outer, mode } => (outer, mode),
2162                                SpatialFitProvenance::NoOuterOptimization => {
2163                                    return Err(
2164                                        "active GAMLSS spatial optimization returned no certified outer provenance"
2165                                            .to_string(),
2166                                    );
2167                                }
2168                            };
2169                            let exact_options =
2170                                crate::outer_subsample::exact_outer_options_for_row_set(
2171                                    options,
2172                                    &crate::row_kernel::RowSet::All,
2173                                );
2174                            fit_custom_family_fixed_log_lambdas_from_owned_mode(
2175                                &family,
2176                                &blocks,
2177                                &exact_options,
2178                                mode,
2179                                theta,
2180                                certified_outer,
2181                            )?
2182                        } else {
2183                            fit_custom_family(&family, &blocks, options)?
2184                        }
2185                    };
2186                    let (mean_beta, noise_beta) = builder.extract_primary_betas(&fit)?;
2187                    mean_beta_hint = Some(mean_beta);
2188                    noise_beta_hint = Some(noise_beta);
2189                    *mean_beta_hint_cell.borrow_mut() = mean_beta_hint.clone();
2190                    *noise_beta_hint_cell.borrow_mut() = noise_beta_hint.clone();
2191                    Ok(fit)
2192                },
2193                |theta,
2194                 specs: &[TermCollectionSpec],
2195                 designs: &[TermCollectionDesign],
2196                 eval_mode,
2197                 row_set: &crate::row_kernel::RowSet| {
2198                    use gam_problem::EvalMode;
2199                    if !analytic_joint_derivatives_available {
2200                        return Err(
2201                            "analytic spatial psi derivatives are unavailable for this exact two-block path"
2202                                .to_string(),
2203                        );
2204                    }
2205                    let rho = theta.slice(s![..joint_setup.rho_dim()]).to_owned();
2206                    let blocks = builder.build_blocks(
2207                        &rho,
2208                        &designs[0],
2209                        &designs[1],
2210                        mean_beta_hint_cell.borrow().clone(),
2211                        noise_beta_hint_cell.borrow().clone(),
2212                    )?;
2213                    if mean_beta_hint_cell.borrow().is_none()
2214                        && let Some(beta) = blocks.first().and_then(|block| block.initial_beta.clone())
2215                    {
2216                        *mean_beta_hint_cell.borrow_mut() = Some(beta);
2217                    }
2218                    if noise_beta_hint_cell.borrow().is_none()
2219                        && let Some(beta) = blocks.get(1).and_then(|block| block.initial_beta.clone())
2220                    {
2221                        *noise_beta_hint_cell.borrow_mut() = Some(beta);
2222                    }
2223                    let family = builder.build_family(&designs[0], &designs[1]);
2224                    let psiderivative_blocks = builder.build_psiderivative_blocks(
2225                        data,
2226                        &specs[0],
2227                        &specs[1],
2228                        &designs[0],
2229                        &designs[1],
2230                    )?;
2231                    let hyper_layout = crate::custom_family::CustomFamilyHyperLayout::new(
2232                        psiderivative_blocks,
2233                        Vec::new(),
2234                        theta.slice(s![joint_setup.rho_dim()..]).to_owned(),
2235                    )?;
2236                    let warm_start = hyper_warm_start_cell.borrow().clone();
2237                    // Forward the κ-staging row set to the family by installing it
2238                    // on the canonical `outer_score_subsample` option. Inner-PIRLS
2239                    // and final covariance still run on full data (the per-row
2240                    // weight is consulted only by outer-only paths inside the
2241                    // family). When the staging schedule is full-data the option
2242                    // stays `None` and the call is equivalent to the prior path.
2243                    let eval_options =
2244                        crate::outer_subsample::exact_outer_options_for_row_set(options, row_set);
2245                    let owned = evaluate_custom_family_joint_hyper_owned(
2246                        &family,
2247                        &blocks,
2248                        &eval_options,
2249                        &rho,
2250                        &hyper_layout,
2251                        warm_start.as_ref(),
2252                        eval_mode,
2253                    )?;
2254                    *hyper_warm_start_cell.borrow_mut() = Some(owned.result.warm_start.clone());
2255                    if !owned.result.inner_converged {
2256                        return Err(
2257                            "exact two-block spatial inner solve did not converge".to_string(),
2258                        );
2259                    }
2260                    if matches!(eval_mode, EvalMode::ValueGradientHessian)
2261                        && !owned.result.outer_hessian.is_analytic()
2262                    {
2263                        return Err(
2264                            "exact two-block spatial objective requires a full joint [rho, psi] hessian"
2265                            .to_string(),
2266                        );
2267                    }
2268                    Ok(ExactJointEvaluation {
2269                        objective: owned.result.objective,
2270                        gradient: owned.result.gradient,
2271                        hessian: owned.result.outer_hessian,
2272                        mode: owned.mode,
2273                    })
2274                },
2275                |theta,
2276                 specs: &[TermCollectionSpec],
2277                 designs: &[TermCollectionDesign],
2278                 row_set: &crate::row_kernel::RowSet| {
2279                    if !analytic_joint_derivatives_available {
2280                        return Err(
2281                            "analytic spatial psi derivatives are unavailable for this exact two-block path"
2282                                .to_string(),
2283                        );
2284                    }
2285                    let rho = theta.slice(s![..joint_setup.rho_dim()]).to_owned();
2286                    let blocks = builder.build_blocks(
2287                        &rho,
2288                        &designs[0],
2289                        &designs[1],
2290                        mean_beta_hint_cell.borrow().clone(),
2291                        noise_beta_hint_cell.borrow().clone(),
2292                    )?;
2293                    if mean_beta_hint_cell.borrow().is_none()
2294                        && let Some(beta) = blocks.first().and_then(|block| block.initial_beta.clone())
2295                    {
2296                        *mean_beta_hint_cell.borrow_mut() = Some(beta);
2297                    }
2298                    if noise_beta_hint_cell.borrow().is_none()
2299                        && let Some(beta) = blocks.get(1).and_then(|block| block.initial_beta.clone())
2300                    {
2301                        *noise_beta_hint_cell.borrow_mut() = Some(beta);
2302                    }
2303                    let family = builder.build_family(&designs[0], &designs[1]);
2304                    let psiderivative_blocks = builder.build_psiderivative_blocks(
2305                        data,
2306                        &specs[0],
2307                        &specs[1],
2308                        &designs[0],
2309                        &designs[1],
2310                    )?;
2311                    let hyper_layout = crate::custom_family::CustomFamilyHyperLayout::new(
2312                        psiderivative_blocks,
2313                        Vec::new(),
2314                        theta.slice(s![joint_setup.rho_dim()..]).to_owned(),
2315                    )?;
2316                    let warm_start = hyper_warm_start_cell.borrow().clone();
2317                    let eval_options =
2318                        crate::outer_subsample::exact_outer_options_for_row_set(options, row_set);
2319                    let owned = evaluate_custom_family_joint_hyper_efs_owned(
2320                        &family,
2321                        &blocks,
2322                        &eval_options,
2323                        &rho,
2324                        &hyper_layout,
2325                        warm_start.as_ref(),
2326                    )?;
2327                    *hyper_warm_start_cell.borrow_mut() = Some(owned.result.warm_start.clone());
2328                    if !owned.result.inner_converged {
2329                        return Err(
2330                            "exact two-block spatial EFS inner solve did not converge".to_string(),
2331                        );
2332                    }
2333                    Ok(ExactJointEfsEvaluation {
2334                        evaluation: owned.result.efs_eval,
2335                        mode: owned.mode,
2336                    })
2337                },
2338                |_beta: &Array1<f64>| Ok(gam_solve::rho_optimizer::SeedOutcome::NoSlot),
2339            )
2340        }};
2341    }
2342
2343    let mut solved = run_exact_joint_spatial!()
2344        .map_err(|err| format!("exact two-block spatial optimization failed: {err}"))?;
2345
2346    let expected_noise_penalty_count = builder.noise_penalty_count(&solved.designs[1]);
2347    let actual_noise_penalty_count = solved.designs[1].penalties.len();
2348    if expected_noise_penalty_count > actual_noise_penalty_count {
2349        if expected_noise_penalty_count != actual_noise_penalty_count + 1 {
2350            return Err(GamlssError::UnsupportedConfiguration {
2351                reason: format!(
2352                    "location-scale result noise design expected {} penalties after augmentation, got {} before augmentation",
2353                    expected_noise_penalty_count, actual_noise_penalty_count
2354                ),
2355            }
2356            .into());
2357        }
2358        append_binomial_log_sigma_shrinkage_penalty_design(&mut solved.designs[1]);
2359    }
2360
2361    BlockwiseTermFitResult::try_from_parts(BlockwiseTermFitResultParts {
2362        fit: solved.fit,
2363        meanspec_resolved: solved.resolved_specs.remove(0),
2364        noisespec_resolved: solved.resolved_specs.remove(0),
2365        mean_design: solved.designs.remove(0),
2366        noise_design: solved.designs.remove(0),
2367    })
2368}
2369
2370pub(crate) struct GaussianLocationScaleTermBuilder {
2371    pub(crate) y: Array1<f64>,
2372    pub(crate) weights: Array1<f64>,
2373    pub(crate) meanspec: TermCollectionSpec,
2374    pub(crate) noisespec: TermCollectionSpec,
2375    pub(crate) mean_offset: Array1<f64>,
2376    pub(crate) noise_offset: Array1<f64>,
2377}
2378
2379impl LocationScaleFamilyBuilder for GaussianLocationScaleTermBuilder {
2380    type Family = GaussianLocationScaleFamily;
2381
2382    fn meanspec(&self) -> &TermCollectionSpec {
2383        &self.meanspec
2384    }
2385
2386    fn noisespec(&self) -> &TermCollectionSpec {
2387        &self.noisespec
2388    }
2389
2390    fn exact_spatial_joint_supported(&self) -> bool {
2391        true
2392    }
2393
2394    fn exact_spatial_seed_risk_profile(&self) -> crate::seeding::SeedRiskProfile {
2395        crate::seeding::SeedRiskProfile::GaussianLocationScale
2396    }
2397
2398    fn build_blocks(
2399        &self,
2400        theta: &Array1<f64>,
2401        mean_design: &TermCollectionDesign,
2402        noise_design: &TermCollectionDesign,
2403        mean_beta_hint: Option<Array1<f64>>,
2404        noise_beta_hint: Option<Array1<f64>>,
2405    ) -> Result<Vec<ParameterBlockSpec>, String> {
2406        let layout = GamlssLambdaLayout::two_block(
2407            mean_design.penalties.len(),
2408            self.noise_penalty_count(noise_design),
2409        );
2410        layout.validate_theta_len(theta.len(), "gaussian location-scale")?;
2411        let (meanspec, noisespec) = build_gaussian_mean_and_scale_blocks(
2412            &self.y,
2413            &self.weights,
2414            mean_design,
2415            noise_design,
2416            &self.mean_offset,
2417            &self.noise_offset,
2418            layout.mean_from(theta),
2419            layout.noise_from(theta),
2420            mean_beta_hint,
2421            noise_beta_hint,
2422            "GaussianLocationScale::build_blocks",
2423        )?;
2424        Ok(vec![meanspec, noisespec])
2425    }
2426
2427    fn build_family(
2428        &self,
2429        mean_design: &TermCollectionDesign,
2430        noise_design: &TermCollectionDesign,
2431    ) -> Self::Family {
2432        let preparednoise_design =
2433            prepared_gaussian_log_sigma_design(&mean_design.design, &noise_design.design)
2434                .expect("prepared Gaussian log-sigma design should match block construction");
2435        GaussianLocationScaleFamily {
2436            y: self.y.clone(),
2437            weights: self.weights.clone(),
2438            mu_design: Some(mean_design.design.clone()),
2439            log_sigma_design: Some(preparednoise_design),
2440            policy: gam_runtime::resource::ResourcePolicy::default_library(),
2441            cached_row_scalars: std::sync::RwLock::new(None),
2442        }
2443    }
2444
2445    fn extract_primary_betas(
2446        &self,
2447        fit: &UnifiedFitResult,
2448    ) -> Result<(Array1<f64>, Array1<f64>), String> {
2449        let mean_beta = fit
2450            .block_states
2451            .get(GaussianLocationScaleFamily::BLOCK_MU)
2452            .ok_or_else(|| "missing Gaussian mu block state".to_string())?
2453            .beta
2454            .clone();
2455        let noise_beta = fit
2456            .block_states
2457            .get(GaussianLocationScaleFamily::BLOCK_LOG_SIGMA)
2458            .ok_or_else(|| "missing Gaussian log_sigma block state".to_string())?
2459            .beta
2460            .clone();
2461        Ok((mean_beta, noise_beta))
2462    }
2463
2464    fn build_psiderivative_blocks(
2465        &self,
2466        data: ndarray::ArrayView2<'_, f64>,
2467        meanspec_resolved: &TermCollectionSpec,
2468        noisespec_resolved: &TermCollectionSpec,
2469        mean_design: &TermCollectionDesign,
2470        noise_design: &TermCollectionDesign,
2471    ) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String> {
2472        let mean_derivs =
2473            build_block_spatial_psi_derivatives(data, meanspec_resolved, mean_design)?
2474                .ok_or_else(|| "missing Gaussian mean spatial psi derivatives".to_string())?;
2475        let noise_derivs =
2476            build_block_spatial_psi_derivatives(data, noisespec_resolved, noise_design)?
2477                .ok_or_else(|| "missing Gaussian log-sigma spatial psi derivatives".to_string())?;
2478        Ok(vec![mean_derivs, noise_derivs])
2479    }
2480}
2481
2482pub(crate) struct GaussianLocationScaleWiggleTermBuilder {
2483    pub(crate) y: Array1<f64>,
2484    pub(crate) weights: Array1<f64>,
2485    pub(crate) meanspec: TermCollectionSpec,
2486    pub(crate) noisespec: TermCollectionSpec,
2487    pub(crate) mean_offset: Array1<f64>,
2488    pub(crate) noise_offset: Array1<f64>,
2489    pub(crate) wiggle_knots: Array1<f64>,
2490    pub(crate) wiggle_degree: usize,
2491    pub(crate) wiggle_block: ParameterBlockInput,
2492}
2493
2494impl LocationScaleFamilyBuilder for GaussianLocationScaleWiggleTermBuilder {
2495    type Family = GaussianLocationScaleWiggleFamily;
2496
2497    fn meanspec(&self) -> &TermCollectionSpec {
2498        &self.meanspec
2499    }
2500
2501    fn noisespec(&self) -> &TermCollectionSpec {
2502        &self.noisespec
2503    }
2504
2505    fn exact_spatial_joint_supported(&self) -> bool {
2506        true
2507    }
2508
2509    fn exact_spatial_seed_risk_profile(&self) -> crate::seeding::SeedRiskProfile {
2510        crate::seeding::SeedRiskProfile::GaussianLocationScale
2511    }
2512
2513    fn require_exact_spatial_joint(&self) -> bool {
2514        true
2515    }
2516
2517    fn extra_rho0(&self) -> Result<Array1<f64>, String> {
2518        initial_log_lambdas_orzeros(&self.wiggle_block)
2519    }
2520
2521    fn build_blocks(
2522        &self,
2523        theta: &Array1<f64>,
2524        mean_design: &TermCollectionDesign,
2525        noise_design: &TermCollectionDesign,
2526        mean_beta_hint: Option<Array1<f64>>,
2527        noise_beta_hint: Option<Array1<f64>>,
2528    ) -> Result<Vec<ParameterBlockSpec>, String> {
2529        let layout = GamlssLambdaLayout::withwiggle(
2530            mean_design.penalties.len(),
2531            self.noise_penalty_count(noise_design),
2532            self.wiggle_block.penalties.len(),
2533        );
2534        layout.validate_theta_len(theta.len(), "gaussian location-scale wiggle")?;
2535        let (mut meanspec, mut noisespec) = build_gaussian_mean_and_scale_blocks(
2536            &self.y,
2537            &self.weights,
2538            mean_design,
2539            noise_design,
2540            &self.mean_offset,
2541            &self.noise_offset,
2542            layout.mean_from(theta),
2543            layout.noise_from(theta),
2544            mean_beta_hint,
2545            noise_beta_hint,
2546            "GaussianLocationScaleWiggle::build_blocks",
2547        )?;
2548        // Keep the dynamic full-width wiggle basis safe from a canonical-gauge
2549        // column drop: route the shared level/intercept alias onto the
2550        // column-reducible mean and log-sigma blocks by giving them a lower
2551        // gauge priority than the wiggle block's fixed 100 (see the binomial
2552        // wiggle path and `build_location_scale_wiggle_block`).
2553        meanspec.gauge_priority = LINK_WIGGLE_GAUGE_PRIORITY;
2554        noisespec.gauge_priority = LINK_WIGGLE_GAUGE_PRIORITY;
2555        let n_rows = meanspec.design.nrows();
2556        let wigglespec = build_location_scale_wiggle_block(
2557            "wiggle",
2558            self.wiggle_block.design.clone(),
2559            self.wiggle_block.offset.clone(),
2560            wiggle_block_penalty_matrices(&self.wiggle_block),
2561            self.wiggle_block.nullspace_dims.clone(),
2562            layout.wiggle_from(theta),
2563            self.wiggle_block.initial_beta.clone(),
2564            n_rows,
2565        )?;
2566        Ok(vec![meanspec, noisespec, wigglespec])
2567    }
2568
2569    fn build_family(
2570        &self,
2571        mean_design: &TermCollectionDesign,
2572        noise_design: &TermCollectionDesign,
2573    ) -> Self::Family {
2574        let preparednoise_design =
2575            prepared_gaussian_log_sigma_design(&mean_design.design, &noise_design.design).expect(
2576                "prepared Gaussian log-sigma design should match wiggle block construction",
2577            );
2578        GaussianLocationScaleWiggleFamily {
2579            y: self.y.clone(),
2580            weights: self.weights.clone(),
2581            mu_design: Some(mean_design.design.clone()),
2582            log_sigma_design: Some(preparednoise_design),
2583            wiggle_knots: self.wiggle_knots.clone(),
2584            wiggle_degree: self.wiggle_degree,
2585            policy: gam_runtime::resource::ResourcePolicy::default_library(),
2586            cached_row_scalars: std::sync::RwLock::new(None),
2587        }
2588    }
2589
2590    fn extract_primary_betas(
2591        &self,
2592        fit: &UnifiedFitResult,
2593    ) -> Result<(Array1<f64>, Array1<f64>), String> {
2594        let mean_beta = fit
2595            .block_states
2596            .get(GaussianLocationScaleWiggleFamily::BLOCK_MU)
2597            .ok_or_else(|| "missing Gaussian wiggle mu block state".to_string())?
2598            .beta
2599            .clone();
2600        let noise_beta = fit
2601            .block_states
2602            .get(GaussianLocationScaleWiggleFamily::BLOCK_LOG_SIGMA)
2603            .ok_or_else(|| "missing Gaussian wiggle log_sigma block state".to_string())?
2604            .beta
2605            .clone();
2606        Ok((mean_beta, noise_beta))
2607    }
2608
2609    fn build_psiderivative_blocks(
2610        &self,
2611        data: ndarray::ArrayView2<'_, f64>,
2612        meanspec_resolved: &TermCollectionSpec,
2613        noisespec_resolved: &TermCollectionSpec,
2614        mean_design: &TermCollectionDesign,
2615        noise_design: &TermCollectionDesign,
2616    ) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String> {
2617        let mean_derivs =
2618            build_block_spatial_psi_derivatives(data, meanspec_resolved, mean_design)?.ok_or_else(
2619                || "missing Gaussian wiggle mean spatial psi derivatives".to_string(),
2620            )?;
2621        let noise_derivs =
2622            build_block_spatial_psi_derivatives(data, noisespec_resolved, noise_design)?
2623                .ok_or_else(|| {
2624                    "missing Gaussian wiggle log-sigma spatial psi derivatives".to_string()
2625                })?;
2626        Ok(vec![mean_derivs, noise_derivs, Vec::new()])
2627    }
2628}
2629
2630pub(crate) struct BinomialLocationScaleTermBuilder {
2631    pub(crate) y: Array1<f64>,
2632    pub(crate) weights: Array1<f64>,
2633    pub(crate) link_kind: InverseLink,
2634    pub(crate) meanspec: TermCollectionSpec,
2635    pub(crate) noisespec: TermCollectionSpec,
2636    pub(crate) mean_offset: Array1<f64>,
2637    pub(crate) noise_offset: Array1<f64>,
2638}
2639
2640impl LocationScaleFamilyBuilder for BinomialLocationScaleTermBuilder {
2641    type Family = BinomialLocationScaleFamily;
2642
2643    fn meanspec(&self) -> &TermCollectionSpec {
2644        &self.meanspec
2645    }
2646
2647    fn noisespec(&self) -> &TermCollectionSpec {
2648        &self.noisespec
2649    }
2650
2651    fn exact_spatial_joint_supported(&self) -> bool {
2652        true
2653    }
2654
2655    fn require_exact_spatial_joint(&self) -> bool {
2656        true
2657    }
2658
2659    fn noise_penalty_count(&self, noise_design: &TermCollectionDesign) -> usize {
2660        noise_design.penalties.len() + 1
2661    }
2662
2663    fn build_blocks(
2664        &self,
2665        theta: &Array1<f64>,
2666        mean_design: &TermCollectionDesign,
2667        noise_design: &TermCollectionDesign,
2668        mean_beta_hint: Option<Array1<f64>>,
2669        noise_beta_hint: Option<Array1<f64>>,
2670    ) -> Result<Vec<ParameterBlockSpec>, String> {
2671        let layout = GamlssLambdaLayout::two_block(
2672            mean_design.penalties.len(),
2673            self.noise_penalty_count(noise_design),
2674        );
2675        layout.validate_theta_len(theta.len(), "binomial location-scale")?;
2676        let (thresholdspec, log_sigmaspec) = build_binomial_threshold_and_scale_blocks(
2677            &self.y,
2678            &self.weights,
2679            &self.link_kind,
2680            mean_design,
2681            noise_design,
2682            &self.mean_offset,
2683            &self.noise_offset,
2684            layout.mean_from(theta),
2685            layout.noise_from(theta),
2686            mean_beta_hint,
2687            noise_beta_hint,
2688            "BinomialLocationScale::build_blocks",
2689        )?;
2690        Ok(vec![thresholdspec, log_sigmaspec])
2691    }
2692
2693    fn build_family(
2694        &self,
2695        mean_design: &TermCollectionDesign,
2696        noise_design: &TermCollectionDesign,
2697    ) -> Self::Family {
2698        let identifiednoise_design =
2699            identified_binomial_log_sigma_design(mean_design, noise_design, &self.weights)
2700                .expect("identified binomial log-sigma design");
2701        BinomialLocationScaleFamily {
2702            y: self.y.clone(),
2703            weights: self.weights.clone(),
2704            link_kind: self.link_kind.clone(),
2705            threshold_design: Some(mean_design.design.clone()),
2706            log_sigma_design: Some(identifiednoise_design),
2707            policy: gam_runtime::resource::ResourcePolicy::default_library(),
2708        }
2709    }
2710
2711    fn extract_primary_betas(
2712        &self,
2713        fit: &UnifiedFitResult,
2714    ) -> Result<(Array1<f64>, Array1<f64>), String> {
2715        let mean_beta = fit
2716            .block_states
2717            .get(BinomialLocationScaleFamily::BLOCK_T)
2718            .ok_or_else(|| "missing Binomial threshold block state".to_string())?
2719            .beta
2720            .clone();
2721        let noise_beta = fit
2722            .block_states
2723            .get(BinomialLocationScaleFamily::BLOCK_LOG_SIGMA)
2724            .ok_or_else(|| "missing Binomial log_sigma block state".to_string())?
2725            .beta
2726            .clone();
2727        Ok((mean_beta, noise_beta))
2728    }
2729
2730    fn build_psiderivative_blocks(
2731        &self,
2732        data: ndarray::ArrayView2<'_, f64>,
2733        meanspec_resolved: &TermCollectionSpec,
2734        noisespec_resolved: &TermCollectionSpec,
2735        mean_design: &TermCollectionDesign,
2736        noise_design: &TermCollectionDesign,
2737    ) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String> {
2738        let mean_derivs =
2739            build_block_spatial_psi_derivatives(data, meanspec_resolved, mean_design)?
2740                .ok_or_else(|| "missing threshold spatial psi derivatives".to_string())?;
2741        let noise_derivs =
2742            build_block_spatial_psi_derivatives(data, noisespec_resolved, noise_design)?
2743                .ok_or_else(|| "missing log_sigma spatial psi derivatives".to_string())?;
2744        Ok(vec![mean_derivs, noise_derivs])
2745    }
2746}
2747
2748pub(crate) struct BinomialLocationScaleWiggleTermBuilder {
2749    pub(crate) y: Array1<f64>,
2750    pub(crate) weights: Array1<f64>,
2751    pub(crate) link_kind: InverseLink,
2752    pub(crate) meanspec: TermCollectionSpec,
2753    pub(crate) noisespec: TermCollectionSpec,
2754    pub(crate) mean_offset: Array1<f64>,
2755    pub(crate) noise_offset: Array1<f64>,
2756    pub(crate) wiggle_knots: Array1<f64>,
2757    pub(crate) wiggle_degree: usize,
2758    pub(crate) wiggle_block: ParameterBlockInput,
2759}
2760
2761impl LocationScaleFamilyBuilder for BinomialLocationScaleWiggleTermBuilder {
2762    type Family = BinomialLocationScaleWiggleFamily;
2763
2764    fn meanspec(&self) -> &TermCollectionSpec {
2765        &self.meanspec
2766    }
2767
2768    fn noisespec(&self) -> &TermCollectionSpec {
2769        &self.noisespec
2770    }
2771
2772    fn exact_spatial_joint_supported(&self) -> bool {
2773        true
2774    }
2775
2776    fn require_exact_spatial_joint(&self) -> bool {
2777        true
2778    }
2779
2780    fn extra_rho0(&self) -> Result<Array1<f64>, String> {
2781        initial_log_lambdas_orzeros(&self.wiggle_block)
2782    }
2783
2784    fn noise_penalty_count(&self, noise_design: &TermCollectionDesign) -> usize {
2785        noise_design.penalties.len() + 1
2786    }
2787
2788    fn build_blocks(
2789        &self,
2790        theta: &Array1<f64>,
2791        mean_design: &TermCollectionDesign,
2792        noise_design: &TermCollectionDesign,
2793        mean_beta_hint: Option<Array1<f64>>,
2794        noise_beta_hint: Option<Array1<f64>>,
2795    ) -> Result<Vec<ParameterBlockSpec>, String> {
2796        let layout = GamlssLambdaLayout::withwiggle(
2797            mean_design.penalties.len(),
2798            self.noise_penalty_count(noise_design),
2799            self.wiggle_block.penalties.len(),
2800        );
2801        layout.validate_theta_len(theta.len(), "wiggle location-scale")?;
2802        let (mut thresholdspec, mut log_sigmaspec) = build_binomial_threshold_and_scale_blocks(
2803            &self.y,
2804            &self.weights,
2805            &self.link_kind,
2806            mean_design,
2807            noise_design,
2808            &self.mean_offset,
2809            &self.noise_offset,
2810            layout.mean_from(theta),
2811            layout.noise_from(theta),
2812            mean_beta_hint,
2813            noise_beta_hint,
2814            "BinomialLocationScaleWiggle::build_blocks",
2815        )?;
2816        // The dynamic monotone wiggle basis is regenerated at full raw width
2817        // every inner iteration and asserts `x.ncols() == spec.design.ncols()`
2818        // in `block_geometry`, so it cannot tolerate a canonical-gauge column
2819        // drop. The level/intercept direction the I-spline shares with the
2820        // threshold block must therefore be routed onto the threshold (and the
2821        // log-sigma) block, whose static designs are column-reducible and
2822        // lifted back via the canonical per-block transform `T`. Give both
2823        // non-wiggle blocks a lower gauge priority than the wiggle block (which
2824        // `build_location_scale_wiggle_block` fixes at 100) so the shared-level
2825        // alias drop lands on them and leaves the dynamic wiggle basis full
2826        // width — mirroring the binomial mean-wiggle path.
2827        thresholdspec.gauge_priority = LINK_WIGGLE_GAUGE_PRIORITY;
2828        log_sigmaspec.gauge_priority = LINK_WIGGLE_GAUGE_PRIORITY;
2829        let n_rows = thresholdspec.design.nrows();
2830        let wigglespec = build_location_scale_wiggle_block(
2831            "wiggle",
2832            self.wiggle_block.design.clone(),
2833            self.wiggle_block.offset.clone(),
2834            wiggle_block_penalty_matrices(&self.wiggle_block),
2835            vec![],
2836            layout.wiggle_from(theta),
2837            self.wiggle_block.initial_beta.clone(),
2838            n_rows,
2839        )?;
2840        Ok(vec![thresholdspec, log_sigmaspec, wigglespec])
2841    }
2842
2843    fn build_family(
2844        &self,
2845        mean_design: &TermCollectionDesign,
2846        noise_design: &TermCollectionDesign,
2847    ) -> Self::Family {
2848        let identifiednoise_design =
2849            identified_binomial_log_sigma_design(mean_design, noise_design, &self.weights)
2850                .expect("identified binomial log-sigma design should match block construction");
2851        BinomialLocationScaleWiggleFamily {
2852            y: self.y.clone(),
2853            weights: self.weights.clone(),
2854            link_kind: self.link_kind.clone(),
2855            threshold_design: Some(mean_design.design.clone()),
2856            log_sigma_design: Some(identifiednoise_design),
2857            wiggle_knots: self.wiggle_knots.clone(),
2858            wiggle_degree: self.wiggle_degree,
2859            policy: gam_runtime::resource::ResourcePolicy::default_library(),
2860        }
2861    }
2862
2863    fn extract_primary_betas(
2864        &self,
2865        fit: &UnifiedFitResult,
2866    ) -> Result<(Array1<f64>, Array1<f64>), String> {
2867        let mean_beta = fit
2868            .block_states
2869            .get(BinomialLocationScaleWiggleFamily::BLOCK_T)
2870            .ok_or_else(|| "missing Binomial wiggle threshold block state".to_string())?
2871            .beta
2872            .clone();
2873        let noise_beta = fit
2874            .block_states
2875            .get(BinomialLocationScaleWiggleFamily::BLOCK_LOG_SIGMA)
2876            .ok_or_else(|| "missing Binomial wiggle log_sigma block state".to_string())?
2877            .beta
2878            .clone();
2879        Ok((mean_beta, noise_beta))
2880    }
2881
2882    fn build_psiderivative_blocks(
2883        &self,
2884        data: ndarray::ArrayView2<'_, f64>,
2885        meanspec_resolved: &TermCollectionSpec,
2886        noisespec_resolved: &TermCollectionSpec,
2887        mean_design: &TermCollectionDesign,
2888        noise_design: &TermCollectionDesign,
2889    ) -> Result<Vec<Vec<CustomFamilyBlockPsiDerivative>>, String> {
2890        let mean_derivs =
2891            build_block_spatial_psi_derivatives(data, meanspec_resolved, mean_design)?
2892                .ok_or_else(|| "missing threshold spatial psi derivatives".to_string())?;
2893        let noise_derivs =
2894            build_block_spatial_psi_derivatives(data, noisespec_resolved, noise_design)?
2895                .ok_or_else(|| "missing log_sigma spatial psi derivatives".to_string())?;
2896        // The wiggle block has no direct spatial design matrix of its own in the
2897        // term builder. Spatial psi moves the wiggle family only through the
2898        // realized threshold/log-sigma designs, which in turn perturb q0 and the
2899        // realized wiggle basis B(q0). The exact joint wiggle psi hooks consume
2900        // those threshold/log-sigma derivative payloads and reconstruct the full
2901        // flattened likelihood-side [rho, psi] calculus internally, so the
2902        // wiggle block intentionally contributes no direct CustomFamilyBlockPsiDerivative
2903        // entries here.
2904        Ok(vec![mean_derivs, noise_derivs, Vec::new()])
2905    }
2906}
2907
2908pub(crate) fn fit_gaussian_location_scale_terms(
2909    data: ndarray::ArrayView2<'_, f64>,
2910    spec: GaussianLocationScaleTermSpec,
2911    options: &BlockwiseFitOptions,
2912    kappa_options: &SpatialLengthScaleOptimizationOptions,
2913) -> Result<BlockwiseTermFitResult, String> {
2914    validate_gaussian_location_scale_termspec(data, &spec, "fit_gaussian_location_scale_terms")?;
2915    fit_location_scale_terms(
2916        data,
2917        GaussianLocationScaleTermBuilder {
2918            y: spec.y,
2919            weights: spec.weights,
2920            meanspec: spec.meanspec,
2921            noisespec: spec.log_sigmaspec,
2922            mean_offset: spec.mean_offset,
2923            noise_offset: spec.log_sigma_offset,
2924        },
2925        options,
2926        kappa_options,
2927    )
2928}
2929
2930pub(crate) fn fit_gaussian_location_scalewiggle_terms(
2931    data: ndarray::ArrayView2<'_, f64>,
2932    spec: GaussianLocationScaleWiggleTermSpec,
2933    options: &BlockwiseFitOptions,
2934    kappa_options: &SpatialLengthScaleOptimizationOptions,
2935) -> Result<BlockwiseTermFitResult, String> {
2936    validate_gaussian_location_scalewiggle_termspec(
2937        data,
2938        &spec,
2939        "fit_gaussian_location_scalewiggle_terms",
2940    )?;
2941    fit_location_scale_terms(
2942        data,
2943        GaussianLocationScaleWiggleTermBuilder {
2944            y: spec.y,
2945            weights: spec.weights,
2946            meanspec: spec.meanspec,
2947            noisespec: spec.log_sigmaspec,
2948            mean_offset: spec.mean_offset,
2949            noise_offset: spec.log_sigma_offset,
2950            wiggle_knots: spec.wiggle_knots,
2951            wiggle_degree: spec.wiggle_degree,
2952            wiggle_block: spec.wiggle_block,
2953        },
2954        options,
2955        kappa_options,
2956    )
2957}
2958
2959pub(crate) fn select_gaussian_location_scale_link_wiggle_basis_from_pilot(
2960    pilot: &BlockwiseTermFitResult,
2961    wiggle_cfg: &WiggleBlockConfig,
2962    wiggle_penalty_orders: &[usize],
2963) -> Result<SelectedWiggleBasis, String> {
2964    let q_seed = pilot
2965        .fit
2966        .block_states
2967        .first()
2968        .ok_or_else(|| "pilot Gaussian wiggle fit is missing mean block".to_string())?
2969        .eta
2970        .view();
2971    select_wiggle_basis_from_seed(q_seed, wiggle_cfg, wiggle_penalty_orders)
2972}
2973
2974pub(crate) fn fit_gaussian_location_scale_terms_with_selected_wiggle(
2975    data: ndarray::ArrayView2<'_, f64>,
2976    spec: GaussianLocationScaleTermSpec,
2977    selected_wiggle_basis: SelectedWiggleBasis,
2978    options: &BlockwiseFitOptions,
2979    kappa_options: &SpatialLengthScaleOptimizationOptions,
2980) -> Result<BlockwiseTermWiggleFitResult, String> {
2981    let SelectedWiggleBasis {
2982        knots: wiggle_knots,
2983        degree: wiggle_degree,
2984        block: wiggle_block,
2985        ..
2986    } = selected_wiggle_basis;
2987    let solved = fit_gaussian_location_scalewiggle_terms(
2988        data,
2989        GaussianLocationScaleWiggleTermSpec {
2990            y: spec.y,
2991            weights: spec.weights,
2992            meanspec: spec.meanspec,
2993            log_sigmaspec: spec.log_sigmaspec,
2994            mean_offset: spec.mean_offset,
2995            log_sigma_offset: spec.log_sigma_offset,
2996            wiggle_knots: wiggle_knots.clone(),
2997            wiggle_degree,
2998            wiggle_block,
2999        },
3000        options,
3001        kappa_options,
3002    )?;
3003
3004    BlockwiseTermWiggleFitResult::try_from_parts(BlockwiseTermWiggleFitResultParts {
3005        fit: solved,
3006        wiggle_knots,
3007        wiggle_degree,
3008    })
3009}
3010
3011pub(crate) fn fit_binomial_location_scale_terms(
3012    data: ndarray::ArrayView2<'_, f64>,
3013    spec: BinomialLocationScaleTermSpec,
3014    options: &BlockwiseFitOptions,
3015    kappa_options: &SpatialLengthScaleOptimizationOptions,
3016) -> Result<BlockwiseTermFitResult, String> {
3017    validate_binomial_location_scale_termspec(data, &spec, "fit_binomial_location_scale_terms")?;
3018    fit_location_scale_terms(
3019        data,
3020        BinomialLocationScaleTermBuilder {
3021            y: spec.y,
3022            weights: spec.weights,
3023            link_kind: spec.link_kind,
3024            meanspec: spec.thresholdspec,
3025            noisespec: spec.log_sigmaspec,
3026            mean_offset: spec.threshold_offset,
3027            noise_offset: spec.log_sigma_offset,
3028        },
3029        options,
3030        kappa_options,
3031    )
3032}
3033
3034pub(crate) fn fit_binomial_location_scalewiggle_terms(
3035    data: ndarray::ArrayView2<'_, f64>,
3036    spec: BinomialLocationScaleWiggleTermSpec,
3037    options: &BlockwiseFitOptions,
3038    kappa_options: &SpatialLengthScaleOptimizationOptions,
3039) -> Result<BlockwiseTermFitResult, String> {
3040    validate_binomial_location_scalewiggle_termspec(
3041        data,
3042        &spec,
3043        "fit_binomial_location_scalewiggle_terms",
3044    )?;
3045    fit_location_scale_terms(
3046        data,
3047        BinomialLocationScaleWiggleTermBuilder {
3048            y: spec.y,
3049            weights: spec.weights,
3050            link_kind: spec.link_kind,
3051            meanspec: spec.thresholdspec,
3052            noisespec: spec.log_sigmaspec,
3053            mean_offset: spec.threshold_offset,
3054            noise_offset: spec.log_sigma_offset,
3055            wiggle_knots: spec.wiggle_knots,
3056            wiggle_degree: spec.wiggle_degree,
3057            wiggle_block: spec.wiggle_block,
3058        },
3059        options,
3060        kappa_options,
3061    )
3062}
3063
3064pub(crate) fn select_binomial_location_scale_link_wiggle_basis_from_pilot(
3065    pilot: &BlockwiseTermFitResult,
3066    wiggle_cfg: &WiggleBlockConfig,
3067    wiggle_penalty_orders: &[usize],
3068) -> Result<SelectedWiggleBasis, String> {
3069    let eta_t = pilot
3070        .fit
3071        .block_states
3072        .first()
3073        .ok_or_else(|| "pilot fit is missing threshold block".to_string())?
3074        .eta
3075        .view();
3076    let eta_ls = pilot
3077        .fit
3078        .block_states
3079        .get(1)
3080        .ok_or_else(|| "pilot fit is missing log_sigma block".to_string())?
3081        .eta
3082        .view();
3083    let sigma = eta_ls.mapv(safe_exp);
3084    let q_seed = Array1::from_iter(eta_t.iter().zip(sigma.iter()).map(|(&t, &s)| -t / s));
3085    select_wiggle_basis_from_seed(q_seed.view(), wiggle_cfg, wiggle_penalty_orders)
3086}
3087
3088pub(crate) fn fit_binomial_location_scale_terms_with_selected_wiggle(
3089    data: ndarray::ArrayView2<'_, f64>,
3090    spec: BinomialLocationScaleTermSpec,
3091    selected_wiggle_basis: SelectedWiggleBasis,
3092    options: &BlockwiseFitOptions,
3093    kappa_options: &SpatialLengthScaleOptimizationOptions,
3094) -> Result<BlockwiseTermWiggleFitResult, String> {
3095    let SelectedWiggleBasis {
3096        knots: wiggle_knots,
3097        degree: wiggle_degree,
3098        block: wiggle_block,
3099        ..
3100    } = selected_wiggle_basis;
3101    let solved = fit_binomial_location_scalewiggle_terms(
3102        data,
3103        BinomialLocationScaleWiggleTermSpec {
3104            y: spec.y,
3105            weights: spec.weights,
3106            link_kind: spec.link_kind,
3107            thresholdspec: spec.thresholdspec,
3108            log_sigmaspec: spec.log_sigmaspec,
3109            threshold_offset: spec.threshold_offset,
3110            log_sigma_offset: spec.log_sigma_offset,
3111            wiggle_knots: wiggle_knots.clone(),
3112            wiggle_degree,
3113            wiggle_block,
3114        },
3115        options,
3116        kappa_options,
3117    )?;
3118
3119    BlockwiseTermWiggleFitResult::try_from_parts(BlockwiseTermWiggleFitResultParts {
3120        fit: solved,
3121        wiggle_knots,
3122        wiggle_degree,
3123    })
3124}
3125
3126pub(crate) fn select_binomial_mean_link_wiggle_basis_from_pilot(
3127    pilot_design: &TermCollectionDesign,
3128    pilot_fit: &UnifiedFitResult,
3129    wiggle_cfg: &WiggleBlockConfig,
3130    wiggle_penalty_orders: &[usize],
3131) -> Result<SelectedWiggleBasis, String> {
3132    let q_seed = pilot_design
3133        .apply(pilot_fit.beta.view())
3134        .map_err(|error| error.to_string())?;
3135    select_wiggle_basis_from_seed(q_seed.view(), wiggle_cfg, wiggle_penalty_orders)
3136}
3137
3138pub(crate) fn fit_binomial_mean_wiggle_terms_with_selected_basis(
3139    data: ndarray::ArrayView2<'_, f64>,
3140    pilot_spec: &TermCollectionSpec,
3141    pilot_design: &TermCollectionDesign,
3142    pilot_fit: &UnifiedFitResult,
3143    y: &Array1<f64>,
3144    weights: &Array1<f64>,
3145    link_kind: InverseLink,
3146    selected_wiggle_basis: SelectedWiggleBasis,
3147    options: &BlockwiseFitOptions,
3148    kappa_options: &SpatialLengthScaleOptimizationOptions,
3149) -> Result<BinomialMeanWiggleTermFitResult, String> {
3150    const RHO_BOUND: f64 = 12.0;
3151
3152    validate_term_weights(
3153        data,
3154        y.len(),
3155        weights,
3156        "fit_binomial_mean_wiggle_terms_with_selected_basis",
3157    )?;
3158    validate_binomial_response(y, "fit_binomial_mean_wiggle_terms_with_selected_basis")?;
3159
3160    // Large-n binomial mean-wiggle fits keep the caller's explicit Hessian
3161    // request. The unified evaluator chooses the scalable exact representation
3162    // (dense for small work, operator HVP for large work) instead of routing to
3163    // gradient-only BFGS by observation count.
3164
3165    let SelectedWiggleBasis {
3166        knots: wiggle_knots,
3167        degree: wiggle_degree,
3168        block: wiggle_block,
3169        ..
3170    } = selected_wiggle_basis;
3171
3172    let spatial_terms = spatial_length_scale_term_indices(pilot_spec);
3173    if spatial_terms.is_empty() {
3174        let (fit, saved_warp_beta, saved_index_shift) = fit_binomial_mean_wiggle(
3175            BinomialMeanWiggleSpec {
3176                y: y.clone(),
3177                weights: weights.clone(),
3178                link_kind,
3179                wiggle_knots: wiggle_knots.clone(),
3180                wiggle_degree,
3181                eta_block: ParameterBlockInput {
3182                    design: pilot_design.design.clone(),
3183                    offset: pilot_design.affine_offset.clone(),
3184                    penalties: pilot_design
3185                        .penalties
3186                        .iter()
3187                        .map(crate::model_types::PenaltySpec::from_blockwise_ref)
3188                        .collect(),
3189                    nullspace_dims: vec![],
3190                    initial_log_lambdas: Some(fitted_log_lambdas(
3191                        &pilot_fit.lambdas,
3192                        "binomial mean-wiggle pilot lambda",
3193                    )?),
3194                    initial_beta: Some(pilot_fit.beta.clone()),
3195                },
3196                wiggle_block,
3197            },
3198            options,
3199        )?;
3200        return Ok(BinomialMeanWiggleTermFitResult {
3201            fit,
3202            resolvedspec: pilot_spec.clone(),
3203            design: pilot_design.clone(),
3204            wiggle_knots,
3205            wiggle_degree,
3206            saved_warp_beta,
3207            saved_index_shift,
3208        });
3209    }
3210
3211    let dims_per_term = spatial_dims_per_term(pilot_spec, &spatial_terms);
3212    let log_kappa0 =
3213        SpatialLogKappaCoords::from_length_scales_aniso(pilot_spec, &spatial_terms, kappa_options)
3214            .reseed_from_data(data, pilot_spec, &spatial_terms, kappa_options)
3215            .map_err(|error| error.to_string())?;
3216    let log_kappa_lower = SpatialLogKappaCoords::lower_bounds_aniso_from_data(
3217        data,
3218        pilot_spec,
3219        &spatial_terms,
3220        &dims_per_term,
3221        kappa_options,
3222    )
3223    .map_err(|error| error.to_string())?;
3224    let log_kappa_upper = SpatialLogKappaCoords::upper_bounds_aniso_from_data(
3225        data,
3226        pilot_spec,
3227        &spatial_terms,
3228        &dims_per_term,
3229        kappa_options,
3230    )
3231    .map_err(|error| error.to_string())?;
3232    // Project seed onto bounds; spec.length_scale is a hint, not a constraint.
3233    let log_kappa0 = log_kappa0.clamp_to_bounds(&log_kappa_lower, &log_kappa_upper);
3234
3235    let eta_penalty_count = pilot_design.penalties.len();
3236    let wiggle_penalty_count = initial_log_lambdas_orzeros(&wiggle_block)?.len();
3237    let rho_dim = eta_penalty_count + wiggle_penalty_count;
3238    let baseline_resolvedspec = log_kappa0
3239        .apply_tospec(pilot_spec, &spatial_terms)
3240        .map_err(|e| e.to_string())?;
3241    let baseline_design =
3242        build_term_collection_design(data, &baseline_resolvedspec).map_err(|e| e.to_string())?;
3243    let baseline_fit = fit_binomial_mean_wiggle(
3244        BinomialMeanWiggleSpec {
3245            y: y.clone(),
3246            weights: weights.clone(),
3247            link_kind: link_kind.clone(),
3248            wiggle_knots: wiggle_knots.clone(),
3249            wiggle_degree,
3250            eta_block: ParameterBlockInput {
3251                design: baseline_design.design.clone(),
3252                offset: baseline_design.affine_offset.clone(),
3253                penalties: baseline_design
3254                    .penalties
3255                    .iter()
3256                    .map(crate::model_types::PenaltySpec::from_blockwise_ref)
3257                    .collect(),
3258                nullspace_dims: vec![],
3259                initial_log_lambdas: Some(fitted_log_lambdas(
3260                    &pilot_fit.lambdas,
3261                    "binomial mean-wiggle pilot lambda",
3262                )?),
3263                initial_beta: Some(pilot_fit.beta.clone()),
3264            },
3265            wiggle_block: wiggle_block.clone(),
3266        },
3267        options,
3268    )?
3269    .0;
3270    let baseline_log_lambdas = fitted_log_lambdas(
3271        &baseline_fit.lambdas,
3272        "binomial mean-wiggle baseline lambda",
3273    )?;
3274    if baseline_log_lambdas.len() != rho_dim {
3275        return Err(GamlssError::DimensionMismatch {
3276            reason: format!(
3277                "baseline binomial mean-wiggle fit returned {} log-lambdas, expected {rho_dim}",
3278                baseline_log_lambdas.len()
3279            ),
3280        }
3281        .into());
3282    }
3283    let baseline_eta_beta = baseline_fit
3284        .block_states
3285        .get(BinomialMeanWiggleFamily::BLOCK_ETA)
3286        .ok_or_else(|| "baseline binomial mean-wiggle fit missing eta block".to_string())?
3287        .beta
3288        .clone();
3289    let baseline_wiggle_beta = Some(
3290        baseline_fit
3291            .block_states
3292            .get(BinomialMeanWiggleFamily::BLOCK_WIGGLE)
3293            .ok_or_else(|| "baseline binomial mean-wiggle fit missing wiggle block".to_string())?
3294            .beta
3295            .clone(),
3296    );
3297    let theta_dim = rho_dim + log_kappa0.len();
3298    let mut theta0 = Array1::<f64>::zeros(theta_dim);
3299    theta0
3300        .slice_mut(s![0..rho_dim])
3301        .assign(&baseline_log_lambdas);
3302    theta0
3303        .slice_mut(s![rho_dim..theta_dim])
3304        .assign(log_kappa0.as_array());
3305
3306    let mut lower = Array1::<f64>::from_elem(theta_dim, -RHO_BOUND);
3307    let mut upper = Array1::<f64>::from_elem(theta_dim, RHO_BOUND);
3308    lower
3309        .slice_mut(s![rho_dim..theta_dim])
3310        .assign(log_kappa_lower.as_array());
3311    upper
3312        .slice_mut(s![rho_dim..theta_dim])
3313        .assign(log_kappa_upper.as_array());
3314
3315    let pilot_spec_cloned = pilot_spec.clone();
3316    let pilot_beta = baseline_eta_beta;
3317    let wiggle_design = wiggle_block.design.clone();
3318    let wiggle_offset = wiggle_block.offset.clone();
3319    let wiggle_penalties = wiggle_block.penalties.clone();
3320    let wiggle_initial_beta = baseline_wiggle_beta;
3321    let wiggle_knots_cloned = wiggle_knots.clone();
3322    let y_cloned = y.clone();
3323    let weights_cloned = weights.clone();
3324    let link_kind_cloned = link_kind.clone();
3325    let outer_family = BinomialMeanWiggleFamily {
3326        y: y_cloned.clone(),
3327        weights: weights_cloned.clone(),
3328        link_kind: link_kind_cloned.clone(),
3329        wiggle_knots: wiggle_knots_cloned.clone(),
3330        wiggle_degree,
3331        policy: gam_runtime::resource::ResourcePolicy::default_library(),
3332        // The spatial joint-κ path keeps the dynamic warp basis (#1596 frozen
3333        // basis applies to the non-spatial `fit_binomial_mean_wiggle` loop).
3334        frozen_warp_design: None,
3335    };
3336    let screening_cap = Arc::new(AtomicUsize::new(0));
3337    let mut outer_options = options.clone();
3338    outer_options.screening_max_inner_iterations = Some(Arc::clone(&screening_cap));
3339    struct MeanWiggleOuterState {
3340        pub(crate) warm_cache: Option<crate::custom_family::CustomFamilyWarmStart>,
3341        pub(crate) last_eval: Option<(
3342            Array1<f64>,
3343            f64,
3344            Array1<f64>,
3345            gam_problem::HessianValue,
3346            crate::custom_family::CustomFamilyWarmStart,
3347        )>,
3348    }
3349
3350    let build_realized_blocks = |theta: &Array1<f64>| -> Result<
3351        (
3352            TermCollectionSpec,
3353            TermCollectionDesign,
3354            Vec<ParameterBlockSpec>,
3355            Vec<CustomFamilyBlockPsiDerivative>,
3356        ),
3357        String,
3358    > {
3359        let log_kappa =
3360            SpatialLogKappaCoords::from_theta_tail_with_dims(theta, rho_dim, dims_per_term.clone());
3361        let resolvedspec = log_kappa
3362            .apply_tospec(&pilot_spec_cloned, &spatial_terms)
3363            .map_err(|e| e.to_string())?;
3364        let design =
3365            build_term_collection_design(data, &resolvedspec).map_err(|e| e.to_string())?;
3366        let eta_derivs = build_block_spatial_psi_derivatives(data, &resolvedspec, &design)?
3367            .ok_or_else(|| {
3368                "missing eta spatial psi derivatives for binomial mean wiggle".to_string()
3369            })?;
3370        let blocks = vec![
3371            ParameterBlockSpec {
3372                name: "eta".to_string(),
3373                design: design.design.clone(),
3374                offset: design.affine_offset.clone(),
3375                penalties: design.penalties_as_penalty_matrix(),
3376                nullspace_dims: vec![],
3377                initial_log_lambdas: theta.slice(s![0..eta_penalty_count]).to_owned(),
3378                initial_beta: Some(pilot_beta.clone()),
3379                // Lower gauge priority on the static eta design: it yields the
3380                // shared level/intercept direction to the dynamic full-width
3381                // wiggle I-spline block (see fit_binomial_mean_wiggle).
3382                gauge_priority: LINK_WIGGLE_GAUGE_PRIORITY,
3383                jacobian_callback: None,
3384                stacked_design: None,
3385                stacked_offset: None,
3386            },
3387            ParameterBlockSpec {
3388                name: "wiggle".to_string(),
3389                design: wiggle_design.clone(),
3390                offset: wiggle_offset.clone(),
3391                penalties: {
3392                    let p_wiggle = wiggle_design.ncols();
3393                    wiggle_penalties
3394                        .iter()
3395                        .map(|spec| match spec {
3396                            crate::model_types::PenaltySpec::Block {
3397                                local, col_range, ..
3398                            } => PenaltyMatrix::Blockwise {
3399                                local: local.clone(),
3400                                col_range: col_range.clone(),
3401                                total_dim: p_wiggle,
3402                            },
3403                            crate::model_types::PenaltySpec::Dense(m)
3404                            | crate::model_types::PenaltySpec::DenseWithMean {
3405                                matrix: m, ..
3406                            } => PenaltyMatrix::Dense(m.clone()),
3407                        })
3408                        .collect()
3409                },
3410                nullspace_dims: vec![],
3411                initial_log_lambdas: theta.slice(s![eta_penalty_count..rho_dim]).to_owned(),
3412                initial_beta: wiggle_initial_beta.clone(),
3413                gauge_priority: DEFAULT_GAUGE_PRIORITY,
3414                jacobian_callback: None,
3415                stacked_design: None,
3416                stacked_offset: None,
3417            },
3418        ];
3419        Ok((resolvedspec, design, blocks, eta_derivs))
3420    };
3421
3422    let build_eval = |theta: &Array1<f64>,
3423                      warm_cache: Option<&crate::custom_family::CustomFamilyWarmStart>,
3424                      need_hessian: bool|
3425     -> Result<
3426        (
3427            crate::custom_family::CustomFamilyJointHyperResult,
3428            TermCollectionSpec,
3429            TermCollectionDesign,
3430        ),
3431        String,
3432    > {
3433        let (resolvedspec, design, blocks, eta_derivs) = build_realized_blocks(theta)?;
3434        let hyper_layout = crate::custom_family::CustomFamilyHyperLayout::new(
3435            vec![eta_derivs, Vec::new()],
3436            Vec::new(),
3437            theta.slice(s![rho_dim..]).to_owned(),
3438        )?;
3439        let eval = evaluate_custom_family_joint_hyper(
3440            &outer_family,
3441            &blocks,
3442            &outer_options,
3443            &theta.slice(s![0..rho_dim]).to_owned(),
3444            &hyper_layout,
3445            warm_cache,
3446            if need_hessian {
3447                gam_problem::EvalMode::ValueGradientHessian
3448            } else {
3449                gam_problem::EvalMode::ValueAndGradient
3450            },
3451        )?;
3452        Ok((eval, resolvedspec, design))
3453    };
3454
3455    let build_efs = |theta: &Array1<f64>,
3456                     warm_cache: Option<&crate::custom_family::CustomFamilyWarmStart>|
3457     -> Result<crate::custom_family::CustomFamilyJointHyperEfsResult, String> {
3458        let (_, _, blocks, eta_derivs) = build_realized_blocks(theta)?;
3459        let hyper_layout = crate::custom_family::CustomFamilyHyperLayout::new(
3460            vec![eta_derivs, Vec::new()],
3461            Vec::new(),
3462            theta.slice(s![rho_dim..]).to_owned(),
3463        )?;
3464        evaluate_custom_family_joint_hyper_efs(
3465            &outer_family,
3466            &blocks,
3467            &outer_options,
3468            &theta.slice(s![0..rho_dim]).to_owned(),
3469            &hyper_layout,
3470            warm_cache,
3471        )
3472        .map_err(|e| e.to_string())
3473    };
3474
3475    use crate::model_types::EstimationError;
3476    use gam_problem::{DeclaredHessianForm, Derivative, OuterEval};
3477    use gam_solve::rho_optimizer::OuterEvalOrder;
3478
3479    // Exact first-order AND second-order [rho, psi] calculus is available
3480    // for all inverse links via the shared jet formulas plus the generic
3481    // exact-Newton D_βH / D²_βH closures routed through
3482    // evaluate_custom_family_joint_hyper -> joint_outer_evaluate ->
3483    // BorrowedJointDerivProvider. Search consumes the exact first-order lane;
3484    // the terminal certificate alone consumes the exact Hessian.
3485    //
3486    // Spatial log-kappa coordinates are ψ (design-moving) dimensions because
3487    // they rebuild the spatial basis and penalties at each outer proposal.
3488    let analytic_outer_hessian_available = true;
3489    let mut seed_heuristic = theta0.to_vec();
3490    for value in &mut seed_heuristic[..rho_dim] {
3491        *value = value.exp();
3492    }
3493    let problem = gam_solve::rho_optimizer::OuterProblem::new(theta_dim)
3494        .with_gradient(Derivative::Analytic)
3495        .with_hessian(if analytic_outer_hessian_available {
3496            DeclaredHessianForm::Either
3497        } else {
3498            DeclaredHessianForm::Unavailable
3499        })
3500        // #2359: the family gradient consumes derivatives through order three;
3501        // reserve its order-four Hessian for the terminal mint certificate.
3502        .with_prefer_gradient_only(true)
3503        .with_psi_dim(theta_dim - rho_dim)
3504        .with_tolerance(options.outer_tol)
3505        .with_max_iter(options.outer_max_iter)
3506        .with_bounds(lower.clone(), upper.clone())
3507        .with_initial_rho(theta0.clone())
3508        .with_seed_config(crate::seeding::SeedConfig {
3509            max_seeds: 4,
3510            seed_budget: 2,
3511            risk_profile: crate::seeding::SeedRiskProfile::GeneralizedLinear,
3512            num_auxiliary_trailing: theta_dim - rho_dim,
3513            ..Default::default()
3514        })
3515        .with_screening_cap(Arc::clone(&screening_cap))
3516        .with_rho_bound(12.0)
3517        .with_heuristic_lambdas(seed_heuristic);
3518
3519    let eval_outer = |state: &mut MeanWiggleOuterState,
3520                      theta: &Array1<f64>,
3521                      order: OuterEvalOrder|
3522     -> Result<OuterEval, EstimationError> {
3523        if let Some((cached_theta, cached_cost, cached_grad, cached_hess, cached_warm)) =
3524            &state.last_eval
3525            && cached_theta == theta
3526            && (!matches!(order, OuterEvalOrder::ValueGradientHessian)
3527                || matches!(
3528                    cached_hess,
3529                    gam_problem::HessianValue::Dense(_) | gam_problem::HessianValue::Operator(_)
3530                ))
3531        {
3532            state.warm_cache = Some(cached_warm.clone());
3533            return Ok(OuterEval {
3534                cost: *cached_cost,
3535                gradient: cached_grad.clone(),
3536                hessian: cached_hess.clone(),
3537                inner_beta_hint: None,
3538            });
3539        }
3540        let need_hessian = matches!(order, OuterEvalOrder::ValueGradientHessian)
3541            && analytic_outer_hessian_available;
3542        let (eval, _, _) = build_eval(theta, state.warm_cache.as_ref(), need_hessian)
3543            .map_err(EstimationError::InvalidInput)?;
3544        if !eval.inner_converged {
3545            state.warm_cache = Some(eval.warm_start);
3546            crate::bail_invalid_estim!(
3547                "binomial mean-wiggle exact spatial inner solve did not converge"
3548            );
3549        }
3550        let hessian_result = eval.outer_hessian.clone();
3551        state.last_eval = Some((
3552            theta.clone(),
3553            eval.objective,
3554            eval.gradient.clone(),
3555            eval.outer_hessian.clone(),
3556            eval.warm_start.clone(),
3557        ));
3558        state.warm_cache = Some(eval.warm_start);
3559        Ok(OuterEval {
3560            cost: eval.objective,
3561            gradient: eval.gradient,
3562            hessian: hessian_result,
3563            inner_beta_hint: None,
3564        })
3565    };
3566
3567    let mut obj = problem.build_objective_with_screening_proxy(
3568        MeanWiggleOuterState {
3569            warm_cache: None,
3570            last_eval: None,
3571        },
3572        |state: &mut MeanWiggleOuterState, theta: &Array1<f64>| {
3573            if let Some((cached_theta, cached_cost, _, _, cached_warm)) = &state.last_eval
3574                && cached_theta == theta
3575            {
3576                state.warm_cache = Some(cached_warm.clone());
3577                return Ok(*cached_cost);
3578            }
3579            let (eval, _, _) = build_eval(theta, state.warm_cache.as_ref(), false)
3580                .map_err(EstimationError::InvalidInput)?;
3581            if !eval.inner_converged {
3582                state.warm_cache = Some(eval.warm_start);
3583                crate::bail_invalid_estim!(
3584                    "binomial mean-wiggle exact spatial cost inner solve did not converge"
3585                        .to_string(),
3586                );
3587            }
3588            state.warm_cache = Some(eval.warm_start);
3589            Ok(eval.objective)
3590        },
3591        |state: &mut MeanWiggleOuterState, theta: &Array1<f64>| {
3592            eval_outer(
3593                state,
3594                theta,
3595                if analytic_outer_hessian_available {
3596                    OuterEvalOrder::ValueGradientHessian
3597                } else {
3598                    OuterEvalOrder::ValueAndGradient
3599                },
3600            )
3601        },
3602        |state: &mut MeanWiggleOuterState, theta: &Array1<f64>, order: OuterEvalOrder| {
3603            eval_outer(state, theta, order)
3604        },
3605        Some(|state: &mut MeanWiggleOuterState| {
3606            state.warm_cache = None;
3607            state.last_eval = None;
3608        }),
3609        Some(|state: &mut MeanWiggleOuterState, theta: &Array1<f64>| {
3610            let eval = build_efs(theta, state.warm_cache.as_ref())
3611                .map_err(EstimationError::InvalidInput)?;
3612            if !eval.inner_converged {
3613                state.warm_cache = Some(eval.warm_start);
3614                crate::bail_invalid_estim!(
3615                    "binomial mean-wiggle exact spatial EFS inner solve did not converge"
3616                        .to_string(),
3617                );
3618            }
3619            state.warm_cache = Some(eval.warm_start);
3620            Ok(eval.efs_eval)
3621        }),
3622        // Seed-screening ranking proxy (#969). The cost closure above
3623        // hard-errors on a non-converged inner solve — correct for
3624        // line-search costs, but under the screening cap (wired into the
3625        // outer options and installed by the cascade) the inner solve is
3626        // truncated BY DESIGN, so screening through it rejects every seed
3627        // — the all-seeds-rejected front-door genus. Screening only RANKS
3628        // candidates: the truncated solve's penalized objective is the
3629        // ranking signal; convergence is demanded of the selected seed's
3630        // full-budget fit, not of capped probes.
3631        |state: &mut MeanWiggleOuterState, theta: &Array1<f64>| {
3632            if let Some((cached_theta, cached_cost, _, _, cached_warm)) = &state.last_eval
3633                && cached_theta == theta
3634            {
3635                state.warm_cache = Some(cached_warm.clone());
3636                return Ok(*cached_cost);
3637            }
3638            let (eval, _, _) = build_eval(theta, state.warm_cache.as_ref(), false)
3639                .map_err(EstimationError::InvalidInput)?;
3640            state.warm_cache = Some(eval.warm_start);
3641            Ok(eval.objective)
3642        },
3643    );
3644
3645    let outer = problem
3646        .run(&mut obj, "binomial mean wiggle exact spatial hyper")
3647        .map_err(|e| e.to_string())?;
3648    if !outer.converged {
3649        return Err(GamlssError::NumericalFailure { reason: format!(
3650            "binomial mean wiggle exact spatial hyper did not converge after {} iterations (final_objective={:.6e}, final_grad_norm={})",
3651            outer.iterations,
3652            outer.final_value,
3653            outer.final_grad_norm_report(),
3654        ) }.into());
3655    }
3656    let theta_star = outer.rho;
3657
3658    let log_kappa =
3659        SpatialLogKappaCoords::from_theta_tail_with_dims(&theta_star, rho_dim, dims_per_term);
3660    let resolvedspec = log_kappa
3661        .apply_tospec(&pilot_spec_cloned, &spatial_terms)
3662        .map_err(|e| e.to_string())?;
3663    let design = build_term_collection_design(data, &resolvedspec).map_err(|e| e.to_string())?;
3664    let resolvedspec =
3665        freeze_term_collection_from_design(&resolvedspec, &design).map_err(|e| e.to_string())?;
3666    let fit = fit_binomial_mean_wiggle(
3667        BinomialMeanWiggleSpec {
3668            y: y_cloned,
3669            weights: weights_cloned,
3670            link_kind: link_kind_cloned,
3671            wiggle_knots: wiggle_knots.clone(),
3672            wiggle_degree,
3673            eta_block: ParameterBlockInput {
3674                design: design.design.clone(),
3675                offset: design.affine_offset.clone(),
3676                penalties: design
3677                    .penalties
3678                    .iter()
3679                    .map(crate::model_types::PenaltySpec::from_blockwise_ref)
3680                    .collect(),
3681                nullspace_dims: vec![],
3682                initial_log_lambdas: Some(theta_star.slice(s![0..eta_penalty_count]).to_owned()),
3683                initial_beta: Some(pilot_beta),
3684            },
3685            wiggle_block: ParameterBlockInput {
3686                design: wiggle_design,
3687                offset: wiggle_offset,
3688                penalties: wiggle_penalties,
3689                nullspace_dims: vec![],
3690                initial_log_lambdas: Some(
3691                    theta_star.slice(s![eta_penalty_count..rho_dim]).to_owned(),
3692                ),
3693                initial_beta: wiggle_initial_beta,
3694            },
3695        },
3696        options,
3697    )?;
3698    let (fit, saved_warp_beta, saved_index_shift) = fit;
3699
3700    Ok(BinomialMeanWiggleTermFitResult {
3701        fit,
3702        resolvedspec,
3703        design,
3704        wiggle_knots,
3705        wiggle_degree,
3706        saved_warp_beta,
3707        saved_index_shift,
3708    })
3709}