Skip to main content

gam_models/gamlss/
errors.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/// Typed errors surfaced from this module's helpers and family
8/// implementations. The `Display` impl writes the carried `reason` verbatim,
9/// so callers that historically returned `Result<_, String>` keep their
10/// user-visible text byte-for-byte identical after coercion via the
11/// `From<GamlssError> for String` impl below.
12#[derive(Debug)]
13pub enum GamlssError {
14    /// Shape, length, row, or column mismatches between matrices,
15    /// vectors, specs, or block configurations.
16    DimensionMismatch { reason: String },
17    /// Generic input validation that doesn't fit a more specific
18    /// variant (e.g. positivity-of-response checks, shape parameter
19    /// must be finite > 0).
20    InvalidInput { reason: String },
21    /// Non-finite values discovered in inputs, coefficients, seeds,
22    /// or intermediate quantities required to remain finite.
23    NonFinite { reason: String },
24    /// A model configuration or feature combination is not supported
25    /// by the requested family / link / engine (e.g. identity link on
26    /// a binomial mean-wiggle family, unexpected design-map variant).
27    UnsupportedConfiguration { reason: String },
28    /// Bound, range, monotonicity, or sign constraints violated by
29    /// supplied parameters or coefficients.
30    ConstraintViolation { reason: String },
31    /// Numerical failures during inner solves, integration, or
32    /// optimization (invalid probabilities, non-finite log-likelihood,
33    /// invalid λ, divergence).
34    NumericalFailure { reason: String },
35}
36
37impl_reason_error_boilerplate! {
38    GamlssError {
39        DimensionMismatch,
40        InvalidInput,
41        NonFinite,
42        UnsupportedConfiguration,
43        ConstraintViolation,
44        NumericalFailure,
45    }
46}
47
48impl From<crate::block_layout::block_count::BlockCountMismatch> for GamlssError {
49    fn from(err: crate::block_layout::block_count::BlockCountMismatch) -> GamlssError {
50        GamlssError::DimensionMismatch {
51            reason: err.message(),
52        }
53    }
54}
55
56/// Numerical floor on μ ∈ (0, 1) used only for downstream `1/μ` and
57/// `1/(1-μ)` divisions and for `μ.ln()` / `(1-μ).ln()` in the generic
58/// composed-link binomial log-likelihood (where the logit-stable
59/// `log_expit` form is unavailable because `q` is the composed link
60/// argument, not the raw logit η). Pure numerical safety, NOT a model
61/// assumption — when the optimizer pushes μ to the floor it indicates a
62/// separated/saturated fit which is detected and surfaced upstream
63/// (`detect_logit_instability`, `Unstable` PIRLS status). For
64/// composed-link μ, derivatives `dμ/dq` etc. are NOT zeroed when the
65/// floor is hit; they carry the legitimate gradient signal of the
66/// outer link and zeroing them would create a phantom flat region that
67/// the optimizer would converge to as a stationary point.
68pub(crate) const MIN_PROB: f64 = 1e-10;
69
70pub(crate) const MIN_DERIV: f64 = 1e-8;
71
72/// Lower clamp on POSITIVE working weights `w_i = (dμ/dη)² / V(μ_i)`
73/// to keep `Xᵀ W X` numerically representable. Strictly numerical:
74/// `w` enters subsequent dense matrix products and a true zero (which
75/// happens when `dμ/dη = 0` at saturation, e.g. logistic μ → 0 with
76/// `dμ/dη = μ(1-μ)`) is harmless but a denormal `w` propagates as
77/// inf/NaN through `XᵀWX` because `w * (x_i x_j)` underflows
78/// non-uniformly. `floor_positiveweight` returns 0 for non-finite or
79/// non-positive inputs (so saturation correctly drops the row from
80/// the inner Newton system); the floor only fires for *strictly
81/// positive* tiny weights. The 1e-12 magnitude is chosen so that
82/// `1e-12 · max|x|² · n` stays comfortably above `f64::MIN_POSITIVE`
83/// at large scale.
84///
85/// This is the canonical positive-weight floor (`1e-12`); the value is owned by
86/// [`gam_problem::MIN_WEIGHT`] so every floored family shares one definition
87/// rather than re-declaring it per module.
88use gam_problem::MIN_WEIGHT;
89
90/// Hard symmetric clamp on η used by the Poisson / Gaussian / Gamma working-
91/// model log-likelihood loops to keep `exp(η)` and `log(σ)` finite under the
92/// IRLS step. Hoisted out of each loop so all three families share the same
93/// numerical regime.
94pub(crate) const ETA_HARD_CLAMP: f64 = 30.0;
95
96/// Saturated `exp(η)` used by every log-link mean reconstruction in this
97/// module: clamp η into `[−ETA_HARD_CLAMP, ETA_HARD_CLAMP]` so `exp` stays
98/// finite, then floor at `MIN_WEIGHT` so downstream divisions never see
99/// exact zero. Centralising the formula here means a tolerance change
100/// propagates to all three families (Poisson / Gaussian / Gamma) without
101/// risk of one path drifting.
102#[inline]
103pub(crate) fn saturated_exp_eta(eta: f64) -> f64 {
104    eta.clamp(-ETA_HARD_CLAMP, ETA_HARD_CLAMP)
105        .exp()
106        .max(MIN_WEIGHT)
107}
108
109/// Floor applied to a fitted smoothing parameter λ before `ln(λ)` is taken to
110/// seed an outer-loop `initial_log_lambdas` warm start. A pilot fit can return
111/// λ underflowed to exactly 0 for a deselected (effectively unpenalized) term;
112/// `ln(0) = -inf` would poison the seed, so we floor at the smallest λ that is
113/// still numerically distinguishable from zero in the log-domain rather than a
114/// modelling-meaningful value. `ln(1e-12) ≈ -27.6` sits well below any λ the
115/// outer optimizer would select, so a genuinely tiny pilot λ still seeds the
116/// search near its lower edge.
117pub(crate) const WARMSTART_LOG_LAMBDA_FLOOR: f64 = 1e-12;
118
119pub(crate) const EXACT_DENSE_BLOCK_BUDGET_BYTES: usize = 512 * 1024 * 1024;
120
121pub(crate) const EXACT_DENSE_TOTAL_BUDGET_BYTES: usize = 2 * 1024 * 1024 * 1024;
122
123pub(crate) const GAMLSS_ROWWISE_PAR_MIN_N: usize = 4096;
124
125pub(crate) const GAMLSS_PROJECTED_TRACE_TARGET_BYTES: usize = 32 * 1024 * 1024;
126
127pub(crate) const GAMLSS_PROJECTED_TRACE_MIN_CHUNK_ROWS: usize = 64;
128
129pub(crate) const GAMLSS_PROJECTED_TRACE_MAX_CHUNK_ROWS: usize = 8192;
130
131pub(crate) fn gamlss_projected_trace_chunk_rows(
132    rank: usize,
133    projected_channel_count: usize,
134    gram_column_count: usize,
135) -> usize {
136    let per_row_values = rank
137        .saturating_mul(projected_channel_count.max(1))
138        .saturating_add(gram_column_count.max(1))
139        .max(1);
140    let per_row_bytes = per_row_values.saturating_mul(std::mem::size_of::<f64>());
141    let rows = GAMLSS_PROJECTED_TRACE_TARGET_BYTES / per_row_bytes.max(1);
142    rows.clamp(
143        GAMLSS_PROJECTED_TRACE_MIN_CHUNK_ROWS,
144        GAMLSS_PROJECTED_TRACE_MAX_CHUNK_ROWS,
145    )
146}
147
148pub(crate) fn gamlss_rowwise_map<F>(n: usize, f: F) -> Array1<f64>
149where
150    F: Fn(usize) -> f64 + Sync,
151{
152    if n >= GAMLSS_ROWWISE_PAR_MIN_N {
153        Array1::from((0..n).into_par_iter().map(&f).collect::<Vec<f64>>())
154    } else {
155        Array1::from_iter((0..n).map(f))
156    }
157}
158
159pub(crate) fn gamlss_rowwise_map_result<F>(n: usize, f: F) -> Result<Array1<f64>, String>
160where
161    F: Fn(usize) -> Result<f64, String> + Sync,
162{
163    if n >= GAMLSS_ROWWISE_PAR_MIN_N {
164        let values: Result<Vec<f64>, String> = (0..n).into_par_iter().map(&f).collect();
165        Ok(Array1::from(values?))
166    } else {
167        let mut out = Array1::<f64>::zeros(n);
168        for i in 0..n {
169            out[i] = f(i)?;
170        }
171        Ok(out)
172    }
173}
174
175pub(crate) enum DenseOrOperator<'a> {
176    Borrowed(&'a Array2<f64>),
177    Owned(Array2<f64>),
178    Operator(DesignMatrix),
179}
180
181impl DenseOrOperator<'_> {
182    pub(crate) fn nrows(&self) -> usize {
183        match self {
184            Self::Borrowed(dense) => dense.nrows(),
185            Self::Owned(dense) => dense.nrows(),
186            Self::Operator(design) => design.nrows(),
187        }
188    }
189
190    pub(crate) fn ncols(&self) -> usize {
191        match self {
192            Self::Borrowed(dense) => dense.ncols(),
193            Self::Owned(dense) => dense.ncols(),
194            Self::Operator(design) => design.ncols(),
195        }
196    }
197
198    pub(crate) fn row_chunk(&self, rows: std::ops::Range<usize>) -> Result<Array2<f64>, String> {
199        match self {
200            Self::Borrowed(dense) => Ok(dense.slice(s![rows, ..]).to_owned()),
201            Self::Owned(dense) => Ok(dense.slice(s![rows, ..]).to_owned()),
202            Self::Operator(design) => design.try_row_chunk(rows).map_err(|e| e.to_string()),
203        }
204    }
205
206    pub(crate) fn dot(&self, beta: ArrayView1<'_, f64>) -> Array1<f64> {
207        let n = self.nrows();
208        let p = self.ncols();
209        assert_eq!(beta.len(), p);
210        match self {
211            Self::Borrowed(dense) => fast_av(*dense, &beta),
212            Self::Owned(dense) => fast_av(dense, &beta),
213            Self::Operator(design) => {
214                let mut out = Array1::<f64>::zeros(n);
215                for rows in exact_design_row_chunks(n, p) {
216                    let chunk = design
217                        .try_row_chunk(rows.clone())
218                        .expect("gamlss DesignSlot::dot: design row chunk materialization failed");
219                    out.slice_mut(s![rows]).assign(&fast_av(&chunk, &beta));
220                }
221                out
222            }
223        }
224    }
225}
226
227/// Resolve a single dense block design from a `ParameterBlockSpec`, falling
228/// back to materializing the sparse representation through the policy when
229/// the dense form isn't already cached. Returns `Cow::Borrowed` whenever the
230/// spec already holds a dense array; `Cow::Owned` only after a forced
231/// materialization. The `materialization_label` string is woven into the
232/// materializer's error so callers can pin which block failed.
233pub(crate) fn dense_block_from_spec<'a>(
234    spec: &'a ParameterBlockSpec,
235    material_policy: &gam_runtime::resource::MaterializationPolicy,
236    materialization_label: &str,
237) -> Result<Cow<'a, Array2<f64>>, String> {
238    match spec.design.as_dense_ref() {
239        Some(d) => Ok(Cow::Borrowed(d)),
240        None => Ok(Cow::Owned(
241            spec.design
242                .try_to_dense_with_policy(material_policy, "gamlss dense_block_from_spec")
243                .map_err(|e| format!("{materialization_label}: {e}"))?
244                .as_ref()
245                .clone(),
246        )),
247    }
248}
249
250/// Resolve the (primary, log-σ) pair of dense block designs that every
251/// LocationScale family's spec-aware exact path needs. The primary block is
252/// the family-specific "mean" axis (μ for Gaussian, latent t for Binomial);
253/// the `short_family_name` ("GaussianLocationScale", "BinomialLocationScale",
254/// or their Wiggle siblings) and `primary_label` ("mu" / "threshold") are
255/// woven into the per-block materialization label for diagnostics.
256pub(crate) fn dense_locscale_block_designs_fromspecs<'a>(
257    specs: &'a [ParameterBlockSpec],
258    expected_count: usize,
259    family_name: &str,
260    short_family_name: &str,
261    primary_block_idx: usize,
262    log_sigma_block_idx: usize,
263    primary_label: &str,
264    material_policy: &gam_runtime::resource::MaterializationPolicy,
265) -> Result<(Cow<'a, Array2<f64>>, Cow<'a, Array2<f64>>), String> {
266    if specs.len() != expected_count {
267        return Err(GamlssError::DimensionMismatch {
268            reason: format!(
269                "{family_name} expects {expected_count} specs, got {}",
270                specs.len()
271            ),
272        }
273        .into());
274    }
275    let primary = dense_block_from_spec(
276        &specs[primary_block_idx],
277        material_policy,
278        &format!("{short_family_name} dense_block_designs_fromspecs {primary_label}"),
279    )?;
280    let log_sigma = dense_block_from_spec(
281        &specs[log_sigma_block_idx],
282        material_policy,
283        &format!("{short_family_name} dense_block_designs_fromspecs log_sigma"),
284    )?;
285    Ok((primary, log_sigma))
286}
287
288/// Assemble the joint log-likelihood gradient `g = ∇_β log L` from a family's
289/// per-block IRLS working sets, in the flattened `β = [β_0; β_1; …]` block
290/// order sized from `specs`.
291///
292/// For a `Diagonal` block the exact coefficient-space score is
293/// `X_bᵀ (w ⊙ (z − η))`: by the IRLS pseudo-response identity
294/// `z_i = η_i + (∂ℓ/∂η_i)/w_i`, the row score is `w_i (z_i − η_i) = ∂ℓ/∂η_i`
295/// **exactly** — independent of whether `w` is the Fisher or the observed
296/// weight (the score/gradient is always the exact observed gradient; only the
297/// Hessian differs between Fisher scoring and observed curvature). An
298/// `ExactNewton` block carries its own analytic gradient. This is the same
299/// single source of truth the inner joint-Newton RHS uses
300/// (`exact_newton_joint_gradient_from_eval`), so a family whose `evaluate()`
301/// emits these working sets gets a joint gradient guaranteed consistent with
302/// its joint Hessian without a bespoke, possibly-disagreeing derivation.
303pub(crate) fn gamlss_joint_gradient_from_working_sets(
304    eval: &FamilyEvaluation,
305    specs: &[ParameterBlockSpec],
306    states: &[ParameterBlockState],
307) -> Result<ExactNewtonJointGradientEvaluation, String> {
308    if eval.blockworking_sets.len() != specs.len() || states.len() != specs.len() {
309        return Err(GamlssError::DimensionMismatch { reason: format!(
310            "gamlss joint gradient: block/spec/state count mismatch (working_sets={}, specs={}, states={})",
311            eval.blockworking_sets.len(),
312            specs.len(),
313            states.len()
314        ) }
315        .into());
316    }
317    let total: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
318    let mut gradient = Array1::<f64>::zeros(total);
319    let mut offset = 0usize;
320    for ((spec, work), state) in specs
321        .iter()
322        .zip(eval.blockworking_sets.iter())
323        .zip(states.iter())
324    {
325        let width = spec.design.ncols();
326        let block_grad = match work {
327            BlockWorkingSet::Diagonal {
328                working_response,
329                working_weights,
330            } => {
331                let n = working_response.len();
332                if working_weights.len() != n || state.eta.len() != n || spec.design.nrows() != n {
333                    return Err(GamlssError::DimensionMismatch { reason: format!(
334                        "gamlss joint gradient: diagonal working-set length mismatch (z={}, w={}, η={}, X_rows={})",
335                        n,
336                        working_weights.len(),
337                        state.eta.len(),
338                        spec.design.nrows()
339                    ) }
340                    .into());
341                }
342                let mut weighted = Array1::<f64>::zeros(n);
343                for i in 0..n {
344                    weighted[i] = working_weights[i] * (working_response[i] - state.eta[i]);
345                }
346                spec.design.transpose_vector_multiply(&weighted)
347            }
348            BlockWorkingSet::ExactNewton {
349                gradient: block_gradient,
350                ..
351            } => block_gradient.clone(),
352        };
353        if block_grad.len() != width {
354            return Err(GamlssError::DimensionMismatch { reason: format!(
355                "gamlss joint gradient: assembled block gradient length {} != design cols {width}",
356                block_grad.len()
357            ) }
358            .into());
359        }
360        gradient
361            .slice_mut(s![offset..offset + width])
362            .assign(&block_grad);
363        offset += width;
364    }
365    Ok(ExactNewtonJointGradientEvaluation {
366        log_likelihood: eval.log_likelihood,
367        gradient,
368    })
369}
370
371/// Materialize a single location-scale family's two cached block designs
372/// (`primary` = mu/threshold, plus `log_sigma`) into dense matrices, borrowing
373/// when the design is already dense and owning a policy-materialized copy
374/// otherwise. Every non-wiggle and wiggle location-scale family's
375/// `dense_block_designs` method is identical bar the accessed field and the
376/// diagnostic labels, so both bits are passed in.
377pub(crate) fn dense_locscale_block_designs_cached<'a>(
378    primary_design: Option<&'a DesignMatrix>,
379    log_sigma_design: Option<&'a DesignMatrix>,
380    family_name: &str,
381    short_family_name: &str,
382    primary_label: &str,
383    material_policy: &gam_runtime::resource::MaterializationPolicy,
384) -> Result<(Cow<'a, Array2<f64>>, Cow<'a, Array2<f64>>), String> {
385    let primary_design = primary_design
386        .ok_or_else(|| format!("{family_name} exact path is missing {primary_label} design"))?;
387    let log_sigma_design = log_sigma_design
388        .ok_or_else(|| format!("{family_name} exact path is missing log-sigma design"))?;
389    let primary = match primary_design.as_dense_ref() {
390        Some(d) => Cow::Borrowed(d),
391        None => Cow::Owned(
392            primary_design
393                .try_to_dense_with_policy(material_policy, "gamlss dense_locscale_block_designs")
394                .map_err(|e| {
395                    format!("{short_family_name} dense_block_designs {primary_label}: {e}")
396                })?
397                .as_ref()
398                .clone(),
399        ),
400    };
401    let log_sigma = match log_sigma_design.as_dense_ref() {
402        Some(d) => Cow::Borrowed(d),
403        None => Cow::Owned(
404            log_sigma_design
405                .try_to_dense_with_policy(material_policy, "gamlss dense_locscale_block_designs")
406                .map_err(|e| format!("{short_family_name} dense_block_designs log_sigma: {e}"))?
407                .as_ref()
408                .clone(),
409        ),
410    };
411    Ok((primary, log_sigma))
412}
413
414/// One resolved ψ-direction for a two-axis (primary + log-σ) location-scale
415/// family. Holds the neutral pieces shared by every such family's
416/// `exact_newton_joint_psi_direction`; each family wraps these into its own
417/// named struct (mu/threshold field renames only).
418pub(crate) struct LocScalePsiDirectionParts {
419    pub(crate) block_idx: usize,
420    pub(crate) local_idx: usize,
421    pub(crate) primary_psi: PsiDesignMap,
422    pub(crate) log_sigma_psi: PsiDesignMap,
423    pub(crate) primary_z: Array1<f64>,
424    pub(crate) log_sigma_z: Array1<f64>,
425}
426
427/// Shared body of every two-axis location-scale family's
428/// `exact_newton_joint_psi_direction`. Walks the flat ψ-derivative list,
429/// resolves the ψ-design map for the selected block (primary = block 0, log-σ
430/// = block 1; the off-axis map is the matching `Zero`), and applies each
431/// block's β via `forward_mul`. The wiggle block (and any other index) yields
432/// `None`, matching the per-family methods. The only per-family variation —
433/// the column counts, the two block betas, the block-list length (2 or 3) and
434/// the diagnostic label prefix — is passed in; the math is identical across
435/// Gaussian/Binomial × wiggle/non-wiggle.
436pub(crate) fn locscale_joint_psi_direction_parts(
437    block_states: &[ParameterBlockState],
438    derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
439    psi_index: usize,
440    n: usize,
441    p_primary: usize,
442    p_log_sigma: usize,
443    primary_block_idx: usize,
444    log_sigma_block_idx: usize,
445    expected_blocks: usize,
446    family_name: &str,
447    primary_label: &str,
448    policy: &gam_runtime::resource::ResourcePolicy,
449) -> Result<Option<LocScalePsiDirectionParts>, String> {
450    validate_block_count::<GamlssError>(family_name, expected_blocks, block_states.len())?;
451    if derivative_blocks.len() != expected_blocks {
452        return Err(GamlssError::DimensionMismatch {
453            reason: format!(
454                "{family_name} joint psi direction expects {expected_blocks} derivative block lists, got {}",
455                derivative_blocks.len()
456            ),
457        }
458        .into());
459    }
460    let beta_primary = &block_states[primary_block_idx].beta;
461    let beta_log_sigma = &block_states[log_sigma_block_idx].beta;
462
463    let mut global = 0usize;
464    for (block_idx, block_derivs) in derivative_blocks.iter().enumerate() {
465        for (local_idx, deriv) in block_derivs.iter().enumerate() {
466            if global == psi_index {
467                let primary_psi;
468                let log_sigma_psi;
469                let primary_z;
470                let log_sigma_z;
471                if block_idx == primary_block_idx {
472                    primary_psi = resolve_custom_family_x_psi_map(
473                        deriv,
474                        n,
475                        p_primary,
476                        0..n,
477                        &format!("{family_name} {primary_label}"),
478                        policy,
479                    )?;
480                    primary_z = primary_psi
481                        .forward_mul(beta_primary.view())
482                        .map_err(|e| format!("{family_name} {primary_label} forward_mul: {e}"))?;
483                    log_sigma_psi = PsiDesignMap::Zero {
484                        nrows: n,
485                        ncols: p_log_sigma,
486                    };
487                    log_sigma_z = Array1::<f64>::zeros(n);
488                } else if block_idx == log_sigma_block_idx {
489                    log_sigma_psi = resolve_custom_family_x_psi_map(
490                        deriv,
491                        n,
492                        p_log_sigma,
493                        0..n,
494                        &format!("{family_name} log-sigma"),
495                        policy,
496                    )?;
497                    log_sigma_z = log_sigma_psi
498                        .forward_mul(beta_log_sigma.view())
499                        .map_err(|e| format!("{family_name} log-sigma forward_mul: {e}"))?;
500                    primary_psi = PsiDesignMap::Zero {
501                        nrows: n,
502                        ncols: p_primary,
503                    };
504                    primary_z = Array1::<f64>::zeros(n);
505                } else {
506                    return Ok(None);
507                }
508                return Ok(Some(LocScalePsiDirectionParts {
509                    block_idx,
510                    local_idx,
511                    primary_psi,
512                    log_sigma_psi,
513                    primary_z,
514                    log_sigma_z,
515                }));
516            }
517            global += 1;
518        }
519    }
520    Ok(None)
521}
522
523/// Shared second-derivative design drift assembly for two-axis location-scale
524/// joint-ψ paths. The family-specific methods differ only by block constants,
525/// labels, and field names; the ψψ map lookup and `X_{ab} β` action are the
526/// same for Gaussian/Binomial and wiggle/non-wiggle variants.
527pub(crate) struct LocScalePsiDriftConfig<'a> {
528    pub(crate) n: usize,
529    pub(crate) p_primary: usize,
530    pub(crate) p_log_sigma: usize,
531    pub(crate) primary_block_idx: usize,
532    pub(crate) log_sigma_block_idx: usize,
533    pub(crate) family_name: &'a str,
534    pub(crate) primary_label: &'a str,
535    pub(crate) policy: &'a gam_runtime::resource::ResourcePolicy,
536}
537
538pub(crate) fn locscale_joint_psisecond_design_drifts(
539    block_states: &[ParameterBlockState],
540    derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
541    psi_a: &LocationScaleJointPsiDirection,
542    psi_b: &LocationScaleJointPsiDirection,
543    cfg: LocScalePsiDriftConfig<'_>,
544) -> Result<LocationScaleJointPsiSecondDrifts, String> {
545    let beta_primary = &block_states[cfg.primary_block_idx].beta;
546    let beta_log_sigma = &block_states[cfg.log_sigma_block_idx].beta;
547    let mut primary_ab_action = None;
548    let mut log_sigma_ab_action = None;
549    let mut primary_ab = None;
550    let mut log_sigma_ab = None;
551
552    // Smooth ψ second derivatives are block-local. Cross-block ψ_a/ψ_b
553    // design second derivatives are therefore zero unless the derivative
554    // payload itself supplies them for the same moving block.
555    if psi_a.block_idx == psi_b.block_idx {
556        let deriv = &derivative_blocks[psi_a.block_idx][psi_a.local_idx];
557        let deriv_b = &derivative_blocks[psi_b.block_idx][psi_b.local_idx];
558        if psi_a.block_idx == cfg.primary_block_idx {
559            let (action, matrix) = psi_psi_map_to_drift_slots(
560                deriv,
561                deriv_b,
562                psi_b.local_idx,
563                cfg.n,
564                cfg.p_primary,
565                &format!("{} {}", cfg.family_name, cfg.primary_label),
566                cfg.policy,
567            )?;
568            primary_ab_action = action;
569            primary_ab = matrix;
570        } else if psi_a.block_idx == cfg.log_sigma_block_idx {
571            let (action, matrix) = psi_psi_map_to_drift_slots(
572                deriv,
573                deriv_b,
574                psi_b.local_idx,
575                cfg.n,
576                cfg.p_log_sigma,
577                &format!("{} log-sigma", cfg.family_name),
578                cfg.policy,
579            )?;
580            log_sigma_ab_action = action;
581            log_sigma_ab = matrix;
582        }
583    }
584
585    let z_primary_ab = second_psi_linear_map(
586        primary_ab_action.as_ref(),
587        primary_ab.as_ref(),
588        cfg.n,
589        cfg.p_primary,
590    )
591    .forward_mul(beta_primary.view());
592    let z_ls_ab = second_psi_linear_map(
593        log_sigma_ab_action.as_ref(),
594        log_sigma_ab.as_ref(),
595        cfg.n,
596        cfg.p_log_sigma,
597    )
598    .forward_mul(beta_log_sigma.view());
599
600    Ok(LocationScaleJointPsiSecondDrifts {
601        x_primary_ab_action: primary_ab_action,
602        x_ls_ab_action: log_sigma_ab_action,
603        x_primary_ab: primary_ab,
604        x_ls_ab: log_sigma_ab,
605        z_primary_ab,
606        z_ls_ab,
607    })
608}
609
610pub(crate) fn psi_psi_map_to_drift_slots(
611    deriv: &crate::custom_family::CustomFamilyBlockPsiDerivative,
612    deriv_b: &crate::custom_family::CustomFamilyBlockPsiDerivative,
613    local_idx_b: usize,
614    n: usize,
615    p: usize,
616    label: &str,
617    policy: &gam_runtime::resource::ResourcePolicy,
618) -> Result<
619    (
620        Option<crate::custom_family::CustomFamilyPsiSecondDesignAction>,
621        Option<Array2<f64>>,
622    ),
623    String,
624> {
625    match resolve_custom_family_x_psi_psi_map(
626        deriv,
627        deriv_b,
628        local_idx_b,
629        n,
630        p,
631        0..n,
632        label,
633        policy,
634    )? {
635        crate::custom_family::PsiDesignMap::Second { action } => Ok((Some(action), None)),
636        crate::custom_family::PsiDesignMap::Dense { matrix } => Ok((None, Some((*matrix).clone()))),
637        crate::custom_family::PsiDesignMap::Zero { .. } => Ok((None, None)),
638        crate::custom_family::PsiDesignMap::First { .. } => {
639            Err(GamlssError::UnsupportedConfiguration {
640                reason: format!("{label}: unexpected First variant from _psi_psi_map"),
641            }
642            .into())
643        }
644    }
645}
646
647pub(crate) fn dense_block_or_operator<'a>(
648    design: &'a DesignMatrix,
649    n: usize,
650    p: usize,
651    budget_bytes: usize,
652    policy: &gam_runtime::resource::ResourcePolicy,
653) -> DenseOrOperator<'a> {
654    if let Some(dense) = design.as_dense_ref() {
655        return DenseOrOperator::Borrowed(dense);
656    }
657
658    let dense_bytes = 8usize.saturating_mul(n).saturating_mul(p);
659    if dense_bytes <= budget_bytes
660        && let Ok(arc) = design
661            .try_to_dense_with_policy(&policy.material_policy(), "gamlss dense_block_or_operator")
662    {
663        return DenseOrOperator::Owned(arc.as_ref().clone());
664    }
665
666    DenseOrOperator::Operator(design.clone())
667}
668
669pub(crate) fn dense_blocks_planned_budget(blocks: &[&DesignMatrix]) -> Vec<usize> {
670    let mut planned = vec![0; blocks.len()];
671    let mut total = 0usize;
672    for (idx, design) in blocks.iter().enumerate() {
673        if design.as_dense_ref().is_some() {
674            continue;
675        }
676        let bytes = 8usize
677            .saturating_mul(design.nrows())
678            .saturating_mul(design.ncols());
679        if bytes <= EXACT_DENSE_BLOCK_BUDGET_BYTES
680            && total.saturating_add(bytes) <= EXACT_DENSE_TOTAL_BUDGET_BYTES
681        {
682            planned[idx] = bytes;
683            total += bytes;
684        }
685    }
686    planned
687}
688
689pub(crate) fn exact_design_row_chunks(
690    n: usize,
691    p: usize,
692) -> impl Iterator<Item = std::ops::Range<usize>> {
693    const TARGET_BYTES: usize = 8 * 1024 * 1024;
694    const MIN_ROWS: usize = 512;
695    const MAX_ROWS: usize = 131_072;
696    let rows = (TARGET_BYTES / (p.max(1) * 8))
697        .clamp(MIN_ROWS, MAX_ROWS)
698        .min(n.max(1));
699    (0..n)
700        .step_by(rows)
701        .map(move |start| start..(start + rows).min(n))
702}
703
704pub(crate) fn design_weighted_column_squares(
705    design: &DesignMatrix,
706    weights: &Array1<f64>,
707) -> Result<Array1<f64>, String> {
708    let n = design.nrows();
709    let p = design.ncols();
710    if weights.len() != n {
711        return Err(GamlssError::DimensionMismatch {
712            reason: format!(
713                "design weighted column squares dimension mismatch: weights={}, rows={}",
714                weights.len(),
715                n
716            ),
717        }
718        .into());
719    }
720    let mut out = Array1::<f64>::zeros(p);
721    for rows in exact_design_row_chunks(n, p) {
722        let chunk = design.try_row_chunk(rows.clone()).map_err(|e| {
723            format!("design weighted column squares row chunk materialization failed: {e}")
724        })?;
725        for (local_i, row) in chunk.outer_iter().enumerate() {
726            let w = weights[rows.start + local_i];
727            if w == 0.0 {
728                continue;
729            }
730            for j in 0..p {
731                let x = row[j];
732                out[j] += w * x * x;
733            }
734        }
735    }
736    Ok(out)
737}
738
739#[inline]
740pub(crate) fn floor_positiveweight(rawweight: f64, minweight: f64) -> f64 {
741    if !rawweight.is_finite() || rawweight <= 0.0 {
742        0.0
743    } else {
744        rawweight.max(minweight)
745    }
746}
747
748#[inline]
749pub(crate) fn logb_dlog_sigma_deta(sigma: f64, d_sigma_deta: f64) -> f64 {
750    if d_sigma_deta.is_infinite() {
751        1.0
752    } else {
753        let value = d_sigma_deta / sigma;
754        if value.is_finite() {
755            value.clamp(0.0, 1.0)
756        } else {
757            0.0
758        }
759    }
760}
761
762#[inline]
763pub(crate) fn gaussian_log_sigma_irlsinfo_directional_derivative(
764    weight: f64,
765    sigma: f64,
766    d_sigma_deta: f64,
767    d_eta: f64,
768) -> f64 {
769    if weight == 0.0 || d_eta == 0.0 || !sigma.is_finite() || sigma <= 0.0 {
770        return 0.0;
771    }
772    // Logb form mirrors gaussian_jointrow_scalars: κ = exp(η)/(b + exp(η)) ∈ [0, 1)
773    // and dκ/dη = κ(1−κ). Use dσ/dη over σ directly so the η → −∞ tail
774    // preserves subnormal information instead of cancelling in `1 − b/σ`;
775    // the helper handles the η → +∞ inf/inf case by returning the analytic
776    // limit 1.
777    let g = logb_dlog_sigma_deta(sigma, d_sigma_deta);
778    if !g.is_finite() || !(0.0..1.0).contains(&g) {
779        return 0.0;
780    }
781    let rawinfo = 2.0 * weight * g * g;
782    if !rawinfo.is_finite() || rawinfo <= MIN_WEIGHT {
783        return 0.0;
784    }
785    let dg_deta = g * (1.0 - g);
786    let dw = 4.0 * weight * g * dg_deta * d_eta;
787    if dw.is_finite() { dw } else { 0.0 }
788}
789
790#[derive(Clone, Copy)]
791pub(crate) struct GaussianDiagonalRowKernel {
792    pub(crate) log_likelihood: f64,
793    pub(crate) location_working_weight: f64,
794    pub(crate) location_working_shift: f64,
795    pub(crate) log_sigma_working_weight: f64,
796    pub(crate) log_sigma_working_response: f64,
797}
798
799#[inline]
800pub(crate) fn gaussian_diagonal_row_kernel(
801    y: f64,
802    location_eta: f64,
803    eta_log_sigma: f64,
804    obs_weight: f64,
805    ln2pi: f64,
806) -> GaussianDiagonalRowKernel {
807    if obs_weight == 0.0 {
808        return GaussianDiagonalRowKernel {
809            log_likelihood: 0.0,
810            location_working_weight: 0.0,
811            location_working_shift: 0.0,
812            log_sigma_working_weight: 0.0,
813            log_sigma_working_response: eta_log_sigma,
814        };
815    }
816
817    // logb noise link σ = b + exp(η) bounds σ ≥ b > 0 by construction, so the
818    // Gaussian location-scale objective ½Σ(y−μ)²/σ² + Σlog σ is bounded below
819    // for any finite data. Its working weight 1/σ² is bounded by 1/b², so
820    // H_μμ has bounded condition number — no after-the-fact floor or cap is
821    // needed (the previous (1e-12, 1e24) clamp was a numerical bandaid for the
822    // pure-exp link's σ→0 singularity and is structurally unnecessary here).
823    // ApproxKind: Exact — working weight analytically bounded in (0, 1/b²].
824    let SigmaJet1 { sigma, d1 } = logb_sigma_jet1_scalar(eta_log_sigma);
825    let inv_s2 = (sigma * sigma).recip();
826    let residual = y - location_eta;
827    let location_working_weight = floor_positiveweight(obs_weight * inv_s2, MIN_WEIGHT);
828    // dlog σ/dη = (∂σ/∂η)/σ = exp(η)/(b + exp(η)) ∈ [0, 1).
829    // Use dσ/dη over σ directly so the η→−∞ tail preserves subnormal
830    // derivative information instead of cancelling in `1 − b/σ`; the helper
831    // returns the analytic limit 1 for the η→+∞ inf/inf case.
832    // Fisher info per obs = 2·(dσ/dη)²/σ² = 2·dlog_sigma_deta², matching the
833    // formula for the pure-exp link (where dlog_sigma_deta ≡ 1).
834    let dlog_sigma_deta = logb_dlog_sigma_deta(sigma, d1);
835    let log_sigma_working_weight = floor_positiveweight(
836        2.0 * obs_weight * dlog_sigma_deta * dlog_sigma_deta,
837        MIN_WEIGHT,
838    );
839    let log_sigma_score = obs_weight * (residual * residual * inv_s2 - 1.0) * dlog_sigma_deta;
840    let log_sigma_working_response = if log_sigma_working_weight == 0.0 {
841        eta_log_sigma
842    } else {
843        eta_log_sigma + log_sigma_score / log_sigma_working_weight
844    };
845
846    GaussianDiagonalRowKernel {
847        log_likelihood: obs_weight
848            * (-0.5 * (residual * residual * inv_s2 + ln2pi + 2.0 * sigma.ln())),
849        location_working_weight,
850        location_working_shift: residual,
851        log_sigma_working_weight,
852        log_sigma_working_response,
853    }
854}
855
856/// Link identifiers for distribution parameters in multi-parameter GAMLSS families.
857#[derive(Clone, Copy, Debug, PartialEq, Eq)]
858pub enum ParameterLink {
859    Identity,
860    Log,
861    Logit,
862    Probit,
863    InverseLink,
864    /// Learnable smooth departure from a known base link.
865    Wiggle,
866}