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    /// A finite predictor/input produced a row likelihood or fitting-geometry
36    /// quantity that cannot be represented by `f64`.  This is deliberately a
37    /// typed refusal: changing the predictor, response, or curvature with a
38    /// clamp/floor would fit a different objective.
39    RowGeometryUnrepresentable {
40        row: usize,
41        quantity: &'static str,
42        eta: f64,
43        value: f64,
44    },
45}
46
47impl std::fmt::Display for GamlssError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::DimensionMismatch { reason }
51            | Self::InvalidInput { reason }
52            | Self::NonFinite { reason }
53            | Self::UnsupportedConfiguration { reason }
54            | Self::ConstraintViolation { reason }
55            | Self::NumericalFailure { reason } => f.write_str(reason),
56            Self::RowGeometryUnrepresentable {
57                row,
58                quantity,
59                eta,
60                value,
61            } => write!(
62                f,
63                "GAMLSS row geometry is not representable at row {row}: {quantity} evaluated from eta={eta:?} produced {value:?}"
64            ),
65        }
66    }
67}
68
69impl std::error::Error for GamlssError {}
70
71impl From<GamlssError> for String {
72    fn from(err: GamlssError) -> String {
73        err.to_string()
74    }
75}
76
77impl From<crate::block_layout::block_count::BlockCountMismatch> for GamlssError {
78    fn from(err: crate::block_layout::block_count::BlockCountMismatch) -> GamlssError {
79        GamlssError::DimensionMismatch {
80            reason: err.message(),
81        }
82    }
83}
84
85pub(crate) const EXACT_DENSE_BLOCK_BUDGET_BYTES: usize = 512 * 1024 * 1024;
86
87pub(crate) const EXACT_DENSE_TOTAL_BUDGET_BYTES: usize = 2 * 1024 * 1024 * 1024;
88
89pub(crate) const GAMLSS_ROWWISE_PAR_MIN_N: usize = 4096;
90
91pub(crate) const GAMLSS_PROJECTED_TRACE_TARGET_BYTES: usize = 32 * 1024 * 1024;
92
93pub(crate) const GAMLSS_PROJECTED_TRACE_MIN_CHUNK_ROWS: usize = 64;
94
95pub(crate) const GAMLSS_PROJECTED_TRACE_MAX_CHUNK_ROWS: usize = 8192;
96
97pub(crate) fn gamlss_projected_trace_chunk_rows(
98    rank: usize,
99    projected_channel_count: usize,
100    gram_column_count: usize,
101) -> usize {
102    let per_row_values = rank
103        .saturating_mul(projected_channel_count.max(1))
104        .saturating_add(gram_column_count.max(1))
105        .max(1);
106    let per_row_bytes = per_row_values.saturating_mul(std::mem::size_of::<f64>());
107    let rows = GAMLSS_PROJECTED_TRACE_TARGET_BYTES / per_row_bytes.max(1);
108    rows.clamp(
109        GAMLSS_PROJECTED_TRACE_MIN_CHUNK_ROWS,
110        GAMLSS_PROJECTED_TRACE_MAX_CHUNK_ROWS,
111    )
112}
113
114pub(crate) fn gamlss_rowwise_map<F>(n: usize, f: F) -> Array1<f64>
115where
116    F: Fn(usize) -> f64 + Sync,
117{
118    if n >= GAMLSS_ROWWISE_PAR_MIN_N {
119        Array1::from((0..n).into_par_iter().map(&f).collect::<Vec<f64>>())
120    } else {
121        Array1::from_iter((0..n).map(f))
122    }
123}
124
125pub(crate) fn gamlss_rowwise_map_result<F>(n: usize, f: F) -> Result<Array1<f64>, String>
126where
127    F: Fn(usize) -> Result<f64, String> + Sync,
128{
129    if n >= GAMLSS_ROWWISE_PAR_MIN_N {
130        let values: Result<Vec<f64>, String> = (0..n).into_par_iter().map(&f).collect();
131        Ok(Array1::from(values?))
132    } else {
133        let mut out = Array1::<f64>::zeros(n);
134        for i in 0..n {
135            out[i] = f(i)?;
136        }
137        Ok(out)
138    }
139}
140
141pub(crate) enum DenseOrOperator<'a> {
142    Borrowed(&'a Array2<f64>),
143    Owned(Array2<f64>),
144    Operator(DesignMatrix),
145}
146
147impl DenseOrOperator<'_> {
148    pub(crate) fn nrows(&self) -> usize {
149        match self {
150            Self::Borrowed(dense) => dense.nrows(),
151            Self::Owned(dense) => dense.nrows(),
152            Self::Operator(design) => design.nrows(),
153        }
154    }
155
156    pub(crate) fn ncols(&self) -> usize {
157        match self {
158            Self::Borrowed(dense) => dense.ncols(),
159            Self::Owned(dense) => dense.ncols(),
160            Self::Operator(design) => design.ncols(),
161        }
162    }
163
164    pub(crate) fn row_chunk(&self, rows: std::ops::Range<usize>) -> Result<Array2<f64>, String> {
165        match self {
166            Self::Borrowed(dense) => Ok(dense.slice(s![rows, ..]).to_owned()),
167            Self::Owned(dense) => Ok(dense.slice(s![rows, ..]).to_owned()),
168            Self::Operator(design) => design.try_row_chunk(rows).map_err(|e| e.to_string()),
169        }
170    }
171
172    pub(crate) fn dot(&self, beta: ArrayView1<'_, f64>) -> Array1<f64> {
173        let n = self.nrows();
174        let p = self.ncols();
175        assert_eq!(beta.len(), p);
176        match self {
177            Self::Borrowed(dense) => fast_av(*dense, &beta),
178            Self::Owned(dense) => fast_av(dense, &beta),
179            Self::Operator(design) => {
180                let mut out = Array1::<f64>::zeros(n);
181                for rows in exact_design_row_chunks(n, p) {
182                    let chunk = design
183                        .try_row_chunk(rows.clone())
184                        .expect("gamlss DesignSlot::dot: design row chunk materialization failed");
185                    out.slice_mut(s![rows]).assign(&fast_av(&chunk, &beta));
186                }
187                out
188            }
189        }
190    }
191}
192
193/// Resolve a single dense block design from a `ParameterBlockSpec`, falling
194/// back to materializing the sparse representation through the policy when
195/// the dense form isn't already cached. Returns `Cow::Borrowed` whenever the
196/// spec already holds a dense array; `Cow::Owned` only after a forced
197/// materialization. The `materialization_label` string is woven into the
198/// materializer's error so callers can pin which block failed.
199pub(crate) fn dense_block_from_spec<'a>(
200    spec: &'a ParameterBlockSpec,
201    material_policy: &gam_runtime::resource::MaterializationPolicy,
202    materialization_label: &str,
203) -> Result<Cow<'a, Array2<f64>>, String> {
204    match spec.design.as_dense_ref() {
205        Some(d) => Ok(Cow::Borrowed(d)),
206        None => Ok(Cow::Owned(
207            spec.design
208                .try_to_dense_with_policy(material_policy, "gamlss dense_block_from_spec")
209                .map_err(|e| format!("{materialization_label}: {e}"))?
210                .as_ref()
211                .clone(),
212        )),
213    }
214}
215
216/// Resolve the (primary, log-σ) pair of dense block designs that every
217/// LocationScale family's spec-aware exact path needs. The primary block is
218/// the family-specific "mean" axis (μ for Gaussian, latent t for Binomial);
219/// the `short_family_name` ("GaussianLocationScale", "BinomialLocationScale",
220/// or their Wiggle siblings) and `primary_label` ("mu" / "threshold") are
221/// woven into the per-block materialization label for diagnostics.
222pub(crate) fn dense_locscale_block_designs_fromspecs<'a>(
223    specs: &'a [ParameterBlockSpec],
224    expected_count: usize,
225    family_name: &str,
226    short_family_name: &str,
227    primary_block_idx: usize,
228    log_sigma_block_idx: usize,
229    primary_label: &str,
230    material_policy: &gam_runtime::resource::MaterializationPolicy,
231) -> Result<(Cow<'a, Array2<f64>>, Cow<'a, Array2<f64>>), String> {
232    if specs.len() != expected_count {
233        return Err(GamlssError::DimensionMismatch {
234            reason: format!(
235                "{family_name} expects {expected_count} specs, got {}",
236                specs.len()
237            ),
238        }
239        .into());
240    }
241    let primary = dense_block_from_spec(
242        &specs[primary_block_idx],
243        material_policy,
244        &format!("{short_family_name} dense_block_designs_fromspecs {primary_label}"),
245    )?;
246    let log_sigma = dense_block_from_spec(
247        &specs[log_sigma_block_idx],
248        material_policy,
249        &format!("{short_family_name} dense_block_designs_fromspecs log_sigma"),
250    )?;
251    Ok((primary, log_sigma))
252}
253
254/// Assemble the joint log-likelihood gradient `g = ∇_β log L` from a family's
255/// per-block IRLS working sets, in the flattened `β = [β_0; β_1; …]` block
256/// order sized from `specs`.
257///
258/// For a `Diagonal` block the exact coefficient-space score is
259/// `X_bᵀ (w ⊙ (z − η))`: by the IRLS pseudo-response identity
260/// `z_i = η_i + (∂ℓ/∂η_i)/w_i`, the row score is `w_i (z_i − η_i) = ∂ℓ/∂η_i`
261/// **exactly** — independent of whether `w` is the Fisher or the observed
262/// weight (the score/gradient is always the exact observed gradient; only the
263/// Hessian differs between Fisher scoring and observed curvature). An
264/// `ExactNewton` block carries its own analytic gradient. This is the same
265/// single source of truth the inner joint-Newton RHS uses
266/// (`exact_newton_joint_gradient_from_eval`), so a family whose `evaluate()`
267/// emits these working sets gets a joint gradient guaranteed consistent with
268/// its joint Hessian without a bespoke, possibly-disagreeing derivation.
269pub(crate) fn gamlss_joint_gradient_from_working_sets(
270    eval: &FamilyEvaluation,
271    specs: &[ParameterBlockSpec],
272    states: &[ParameterBlockState],
273) -> Result<ExactNewtonJointGradientEvaluation, String> {
274    if eval.blockworking_sets.len() != specs.len() || states.len() != specs.len() {
275        return Err(GamlssError::DimensionMismatch { reason: format!(
276            "gamlss joint gradient: block/spec/state count mismatch (working_sets={}, specs={}, states={})",
277            eval.blockworking_sets.len(),
278            specs.len(),
279            states.len()
280        ) }
281        .into());
282    }
283    let total: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
284    let mut gradient = Array1::<f64>::zeros(total);
285    let mut offset = 0usize;
286    for ((spec, work), state) in specs
287        .iter()
288        .zip(eval.blockworking_sets.iter())
289        .zip(states.iter())
290    {
291        let width = spec.design.ncols();
292        let block_grad = match work {
293            BlockWorkingSet::Diagonal {
294                working_response,
295                working_weights,
296            } => {
297                let n = working_response.len();
298                if working_weights.len() != n || state.eta.len() != n || spec.design.nrows() != n {
299                    return Err(GamlssError::DimensionMismatch { reason: format!(
300                        "gamlss joint gradient: diagonal working-set length mismatch (z={}, w={}, η={}, X_rows={})",
301                        n,
302                        working_weights.len(),
303                        state.eta.len(),
304                        spec.design.nrows()
305                    ) }
306                    .into());
307                }
308                let mut weighted = Array1::<f64>::zeros(n);
309                for i in 0..n {
310                    weighted[i] = working_weights[i] * (working_response[i] - state.eta[i]);
311                }
312                spec.design.transpose_vector_multiply(&weighted)
313            }
314            BlockWorkingSet::ExactNewton {
315                gradient: block_gradient,
316                ..
317            } => block_gradient.clone(),
318        };
319        if block_grad.len() != width {
320            return Err(GamlssError::DimensionMismatch { reason: format!(
321                "gamlss joint gradient: assembled block gradient length {} != design cols {width}",
322                block_grad.len()
323            ) }
324            .into());
325        }
326        gradient
327            .slice_mut(s![offset..offset + width])
328            .assign(&block_grad);
329        offset += width;
330    }
331    Ok(ExactNewtonJointGradientEvaluation {
332        log_likelihood: eval.log_likelihood,
333        gradient,
334    })
335}
336
337/// Materialize a single location-scale family's two cached block designs
338/// (`primary` = mu/threshold, plus `log_sigma`) into dense matrices, borrowing
339/// when the design is already dense and owning a policy-materialized copy
340/// otherwise. Every non-wiggle and wiggle location-scale family's
341/// `dense_block_designs` method is identical bar the accessed field and the
342/// diagnostic labels, so both bits are passed in.
343pub(crate) fn dense_locscale_block_designs_cached<'a>(
344    primary_design: Option<&'a DesignMatrix>,
345    log_sigma_design: Option<&'a DesignMatrix>,
346    family_name: &str,
347    short_family_name: &str,
348    primary_label: &str,
349    material_policy: &gam_runtime::resource::MaterializationPolicy,
350) -> Result<(Cow<'a, Array2<f64>>, Cow<'a, Array2<f64>>), String> {
351    let primary_design = primary_design
352        .ok_or_else(|| format!("{family_name} exact path is missing {primary_label} design"))?;
353    let log_sigma_design = log_sigma_design
354        .ok_or_else(|| format!("{family_name} exact path is missing log-sigma design"))?;
355    let primary = match primary_design.as_dense_ref() {
356        Some(d) => Cow::Borrowed(d),
357        None => Cow::Owned(
358            primary_design
359                .try_to_dense_with_policy(material_policy, "gamlss dense_locscale_block_designs")
360                .map_err(|e| {
361                    format!("{short_family_name} dense_block_designs {primary_label}: {e}")
362                })?
363                .as_ref()
364                .clone(),
365        ),
366    };
367    let log_sigma = match log_sigma_design.as_dense_ref() {
368        Some(d) => Cow::Borrowed(d),
369        None => Cow::Owned(
370            log_sigma_design
371                .try_to_dense_with_policy(material_policy, "gamlss dense_locscale_block_designs")
372                .map_err(|e| format!("{short_family_name} dense_block_designs log_sigma: {e}"))?
373                .as_ref()
374                .clone(),
375        ),
376    };
377    Ok((primary, log_sigma))
378}
379
380/// One resolved ψ-direction for a two-axis (primary + log-σ) location-scale
381/// family. Holds the neutral pieces shared by every such family's
382/// `exact_newton_joint_psi_direction`; each family wraps these into its own
383/// named struct (mu/threshold field renames only).
384pub(crate) struct LocScalePsiDirectionParts {
385    pub(crate) block_idx: usize,
386    pub(crate) local_idx: usize,
387    pub(crate) primary_psi: PsiDesignMap,
388    pub(crate) log_sigma_psi: PsiDesignMap,
389    pub(crate) primary_z: Array1<f64>,
390    pub(crate) log_sigma_z: Array1<f64>,
391}
392
393/// Shared body of every two-axis location-scale family's
394/// `exact_newton_joint_psi_direction`. Walks the flat ψ-derivative list,
395/// resolves the ψ-design map for the selected block (primary = block 0, log-σ
396/// = block 1; the off-axis map is the matching `Zero`), and applies each
397/// block's β via `forward_mul`. The wiggle block (and any other index) yields
398/// `None`, matching the per-family methods. The only per-family variation —
399/// the column counts, the two block betas, the block-list length (2 or 3) and
400/// the diagnostic label prefix — is passed in; the math is identical across
401/// Gaussian/Binomial × wiggle/non-wiggle.
402pub(crate) fn locscale_joint_psi_direction_parts(
403    block_states: &[ParameterBlockState],
404    derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
405    psi_index: usize,
406    n: usize,
407    p_primary: usize,
408    p_log_sigma: usize,
409    primary_block_idx: usize,
410    log_sigma_block_idx: usize,
411    expected_blocks: usize,
412    family_name: &str,
413    primary_label: &str,
414    policy: &gam_runtime::resource::ResourcePolicy,
415) -> Result<Option<LocScalePsiDirectionParts>, String> {
416    validate_block_count::<GamlssError>(family_name, expected_blocks, block_states.len())?;
417    if derivative_blocks.len() != expected_blocks {
418        return Err(GamlssError::DimensionMismatch {
419            reason: format!(
420                "{family_name} joint psi direction expects {expected_blocks} derivative block lists, got {}",
421                derivative_blocks.len()
422            ),
423        }
424        .into());
425    }
426    let beta_primary = &block_states[primary_block_idx].beta;
427    let beta_log_sigma = &block_states[log_sigma_block_idx].beta;
428
429    let mut global = 0usize;
430    for (block_idx, block_derivs) in derivative_blocks.iter().enumerate() {
431        for (local_idx, deriv) in block_derivs.iter().enumerate() {
432            if global == psi_index {
433                let primary_psi;
434                let log_sigma_psi;
435                let primary_z;
436                let log_sigma_z;
437                if block_idx == primary_block_idx {
438                    primary_psi = resolve_custom_family_x_psi_map(
439                        deriv,
440                        n,
441                        p_primary,
442                        0..n,
443                        &format!("{family_name} {primary_label}"),
444                        policy,
445                    ).map_err(|error| error.to_string())?;
446                    primary_z = primary_psi
447                        .forward_mul(beta_primary.view())
448                        .map_err(|e| format!("{family_name} {primary_label} forward_mul: {e}"))?;
449                    log_sigma_psi = PsiDesignMap::Zero {
450                        nrows: n,
451                        ncols: p_log_sigma,
452                    };
453                    log_sigma_z = Array1::<f64>::zeros(n);
454                } else if block_idx == log_sigma_block_idx {
455                    log_sigma_psi = resolve_custom_family_x_psi_map(
456                        deriv,
457                        n,
458                        p_log_sigma,
459                        0..n,
460                        &format!("{family_name} log-sigma"),
461                        policy,
462                    ).map_err(|error| error.to_string())?;
463                    log_sigma_z = log_sigma_psi
464                        .forward_mul(beta_log_sigma.view())
465                        .map_err(|e| format!("{family_name} log-sigma forward_mul: {e}"))?;
466                    primary_psi = PsiDesignMap::Zero {
467                        nrows: n,
468                        ncols: p_primary,
469                    };
470                    primary_z = Array1::<f64>::zeros(n);
471                } else {
472                    return Ok(None);
473                }
474                return Ok(Some(LocScalePsiDirectionParts {
475                    block_idx,
476                    local_idx,
477                    primary_psi,
478                    log_sigma_psi,
479                    primary_z,
480                    log_sigma_z,
481                }));
482            }
483            global += 1;
484        }
485    }
486    Ok(None)
487}
488
489/// Shared second-derivative design drift assembly for two-axis location-scale
490/// joint-ψ paths. The family-specific methods differ only by block constants,
491/// labels, and field names; the ψψ map lookup and `X_{ab} β` action are the
492/// same for Gaussian/Binomial and wiggle/non-wiggle variants.
493pub(crate) struct LocScalePsiDriftConfig<'a> {
494    pub(crate) n: usize,
495    pub(crate) p_primary: usize,
496    pub(crate) p_log_sigma: usize,
497    pub(crate) primary_block_idx: usize,
498    pub(crate) log_sigma_block_idx: usize,
499    pub(crate) family_name: &'a str,
500    pub(crate) primary_label: &'a str,
501    pub(crate) policy: &'a gam_runtime::resource::ResourcePolicy,
502}
503
504pub(crate) fn locscale_joint_psisecond_design_drifts(
505    block_states: &[ParameterBlockState],
506    derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
507    psi_a: &LocationScaleJointPsiDirection,
508    psi_b: &LocationScaleJointPsiDirection,
509    cfg: LocScalePsiDriftConfig<'_>,
510) -> Result<LocationScaleJointPsiSecondDrifts, String> {
511    let beta_primary = &block_states[cfg.primary_block_idx].beta;
512    let beta_log_sigma = &block_states[cfg.log_sigma_block_idx].beta;
513    let mut primary_ab_action = None;
514    let mut log_sigma_ab_action = None;
515    let mut primary_ab = None;
516    let mut log_sigma_ab = None;
517
518    // Smooth ψ second derivatives are block-local. Cross-block ψ_a/ψ_b
519    // design second derivatives are therefore zero unless the derivative
520    // payload itself supplies them for the same moving block.
521    if psi_a.block_idx == psi_b.block_idx {
522        let deriv = &derivative_blocks[psi_a.block_idx][psi_a.local_idx];
523        let deriv_b = &derivative_blocks[psi_b.block_idx][psi_b.local_idx];
524        if psi_a.block_idx == cfg.primary_block_idx {
525            let (action, matrix) = psi_psi_map_to_drift_slots(
526                deriv,
527                deriv_b,
528                psi_b.local_idx,
529                cfg.n,
530                cfg.p_primary,
531                &format!("{} {}", cfg.family_name, cfg.primary_label),
532                cfg.policy,
533            )?;
534            primary_ab_action = action;
535            primary_ab = matrix;
536        } else if psi_a.block_idx == cfg.log_sigma_block_idx {
537            let (action, matrix) = psi_psi_map_to_drift_slots(
538                deriv,
539                deriv_b,
540                psi_b.local_idx,
541                cfg.n,
542                cfg.p_log_sigma,
543                &format!("{} log-sigma", cfg.family_name),
544                cfg.policy,
545            )?;
546            log_sigma_ab_action = action;
547            log_sigma_ab = matrix;
548        }
549    }
550
551    let z_primary_ab = second_psi_linear_map(
552        primary_ab_action.as_ref(),
553        primary_ab.as_ref(),
554        cfg.n,
555        cfg.p_primary,
556    )
557    .forward_mul(beta_primary.view());
558    let z_ls_ab = second_psi_linear_map(
559        log_sigma_ab_action.as_ref(),
560        log_sigma_ab.as_ref(),
561        cfg.n,
562        cfg.p_log_sigma,
563    )
564    .forward_mul(beta_log_sigma.view());
565
566    Ok(LocationScaleJointPsiSecondDrifts {
567        x_primary_ab_action: primary_ab_action,
568        x_ls_ab_action: log_sigma_ab_action,
569        x_primary_ab: primary_ab,
570        x_ls_ab: log_sigma_ab,
571        z_primary_ab,
572        z_ls_ab,
573    })
574}
575
576pub(crate) fn psi_psi_map_to_drift_slots(
577    deriv: &crate::custom_family::CustomFamilyBlockPsiDerivative,
578    deriv_b: &crate::custom_family::CustomFamilyBlockPsiDerivative,
579    local_idx_b: usize,
580    n: usize,
581    p: usize,
582    label: &str,
583    policy: &gam_runtime::resource::ResourcePolicy,
584) -> Result<
585    (
586        Option<crate::custom_family::CustomFamilyPsiSecondDesignAction>,
587        Option<Array2<f64>>,
588    ),
589    String,
590> {
591    match resolve_custom_family_x_psi_psi_map(
592        deriv,
593        deriv_b,
594        local_idx_b,
595        n,
596        p,
597        0..n,
598        label,
599        policy,
600    ).map_err(|error| error.to_string())? {
601        crate::custom_family::PsiDesignMap::Second { action } => Ok((Some(action), None)),
602        crate::custom_family::PsiDesignMap::Dense { matrix } => Ok((None, Some((*matrix).clone()))),
603        crate::custom_family::PsiDesignMap::Zero { .. } => Ok((None, None)),
604        crate::custom_family::PsiDesignMap::First { .. } => {
605            Err(GamlssError::UnsupportedConfiguration {
606                reason: format!("{label}: unexpected First variant from _psi_psi_map"),
607            }
608            .into())
609        }
610    }
611}
612
613pub(crate) fn dense_block_or_operator<'a>(
614    design: &'a DesignMatrix,
615    n: usize,
616    p: usize,
617    budget_bytes: usize,
618    policy: &gam_runtime::resource::ResourcePolicy,
619) -> DenseOrOperator<'a> {
620    if let Some(dense) = design.as_dense_ref() {
621        return DenseOrOperator::Borrowed(dense);
622    }
623
624    let dense_bytes = 8usize.saturating_mul(n).saturating_mul(p);
625    if dense_bytes <= budget_bytes
626        && let Ok(arc) = design
627            .try_to_dense_with_policy(&policy.material_policy(), "gamlss dense_block_or_operator")
628    {
629        return DenseOrOperator::Owned(arc.as_ref().clone());
630    }
631
632    DenseOrOperator::Operator(design.clone())
633}
634
635pub(crate) fn dense_blocks_planned_budget(blocks: &[&DesignMatrix]) -> Vec<usize> {
636    let mut planned = vec![0; blocks.len()];
637    let mut total = 0usize;
638    for (idx, design) in blocks.iter().enumerate() {
639        if design.as_dense_ref().is_some() {
640            continue;
641        }
642        let bytes = 8usize
643            .saturating_mul(design.nrows())
644            .saturating_mul(design.ncols());
645        if bytes <= EXACT_DENSE_BLOCK_BUDGET_BYTES
646            && total.saturating_add(bytes) <= EXACT_DENSE_TOTAL_BUDGET_BYTES
647        {
648            planned[idx] = bytes;
649            total += bytes;
650        }
651    }
652    planned
653}
654
655pub(crate) fn exact_design_row_chunks(
656    n: usize,
657    p: usize,
658) -> impl Iterator<Item = std::ops::Range<usize>> {
659    // Imported, not transcribed (#2704).
660    const TARGET_BYTES: usize = gam_runtime::resource::LIBRARY_ROW_CHUNK_TARGET_BYTES;
661    const MIN_ROWS: usize = 512;
662    const MAX_ROWS: usize = 131_072;
663    let rows = (TARGET_BYTES / (p.max(1) * 8))
664        .clamp(MIN_ROWS, MAX_ROWS)
665        .min(n.max(1));
666    (0..n)
667        .step_by(rows)
668        .map(move |start| start..(start + rows).min(n))
669}
670
671pub(crate) fn design_weighted_column_squares(
672    design: &DesignMatrix,
673    weights: &Array1<f64>,
674) -> Result<Array1<f64>, String> {
675    let n = design.nrows();
676    let p = design.ncols();
677    if weights.len() != n {
678        return Err(GamlssError::DimensionMismatch {
679            reason: format!(
680                "design weighted column squares dimension mismatch: weights={}, rows={}",
681                weights.len(),
682                n
683            ),
684        }
685        .into());
686    }
687    let mut out = Array1::<f64>::zeros(p);
688    for rows in exact_design_row_chunks(n, p) {
689        let chunk = design.try_row_chunk(rows.clone()).map_err(|e| {
690            format!("design weighted column squares row chunk materialization failed: {e}")
691        })?;
692        for (local_i, row) in chunk.outer_iter().enumerate() {
693            let w = weights[rows.start + local_i];
694            if w == 0.0 {
695                continue;
696            }
697            for j in 0..p {
698                let x = row[j];
699                out[j] += w * x * x;
700            }
701        }
702    }
703    Ok(out)
704}
705
706#[inline]
707pub(crate) fn logb_dlog_sigma_deta(sigma: f64, d_sigma_deta: f64) -> f64 {
708    d_sigma_deta / sigma
709}
710
711/// Exact power-of-two decomposition `x = mantissa * 2^exponent` for a positive
712/// finite `f64`, including subnormals. The mantissa lies in `[1, 2)`.
713#[inline]
714pub(crate) fn positive_frexp(x: f64) -> (f64, i32) {
715    assert!(x.is_finite() && x > 0.0);
716    let bits = x.to_bits();
717    let raw_exp = ((bits >> 52) & 0x7ff) as i32;
718    let fraction = bits & ((1_u64 << 52) - 1);
719    if raw_exp != 0 {
720        let mantissa = f64::from_bits((1023_u64 << 52) | fraction);
721        (mantissa, raw_exp - 1023)
722    } else {
723        let leading = 63_i32 - fraction.leading_zeros() as i32;
724        let shift = 52_i32 - leading;
725        let normalized = fraction << shift;
726        let mantissa = f64::from_bits((1023_u64 << 52) | (normalized & ((1_u64 << 52) - 1)));
727        (mantissa, -1022 - shift)
728    }
729}
730
731#[inline]
732pub(crate) fn scale_normalized_power_of_two(mut mantissa: f64, mut exponent: i32) -> f64 {
733    while mantissa >= 2.0 {
734        mantissa *= 0.5;
735        exponent += 1;
736    }
737    while mantissa < 1.0 {
738        mantissa *= 2.0;
739        exponent -= 1;
740    }
741    if exponent > 1023 {
742        return f64::INFINITY;
743    }
744    if exponent >= -1022 {
745        let power = f64::from_bits(((exponent + 1023) as u64) << 52);
746        return mantissa * power;
747    }
748    if exponent < -1075 {
749        return 0.0;
750    }
751    let units = mantissa * 2.0_f64.powi(exponent + 1074);
752    units * f64::from_bits(1)
753}
754
755/// Compute `a*b*c/d` for positive finite inputs while carrying the binary
756/// exponent separately. Intermediate overflow/underflow therefore cannot
757/// change a representable final result.
758#[inline]
759pub(crate) fn scaled_positive_product_quotient(a: f64, b: f64, c: f64, d: f64) -> f64 {
760    assert!(a.is_finite() && a > 0.0);
761    assert!(b.is_finite() && b > 0.0);
762    assert!(c.is_finite() && c > 0.0);
763    assert!(d.is_finite() && d > 0.0);
764    let (ma, ea) = positive_frexp(a);
765    let (mb, eb) = positive_frexp(b);
766    let (mc, ec) = positive_frexp(c);
767    let (md, ed) = positive_frexp(d);
768    scale_normalized_power_of_two((ma * mb) * (mc / md), ea + eb + ec - ed)
769}
770
771#[inline]
772pub(crate) fn scaled_signed_product3(a: f64, b: f64, c: f64) -> f64 {
773    if a == 0.0 || b == 0.0 || c == 0.0 {
774        return 0.0;
775    }
776    let sign = a.signum() * b.signum() * c.signum();
777    sign * scaled_positive_product_quotient(a.abs(), b.abs(), c.abs(), 1.0)
778}
779
780#[inline]
781pub(crate) fn gaussian_log_sigma_irlsinfo_directional_derivative(
782    row: usize,
783    eta: f64,
784    weight: f64,
785    sigma: f64,
786    d_sigma_deta: f64,
787    d_eta: f64,
788) -> Result<f64, String> {
789    if weight == 0.0 || d_eta == 0.0 {
790        return Ok(0.0);
791    }
792    let g = logb_dlog_sigma_deta(sigma, d_sigma_deta);
793    if !g.is_finite() || g <= 0.0 || g > 1.0 {
794        return Err(GamlssError::RowGeometryUnrepresentable {
795            row,
796            quantity: "Gaussian log-scale link derivative",
797            eta,
798            value: g,
799        }
800        .into());
801    }
802    let info = scaled_positive_product_quotient(weight, g, g, 0.5);
803    if !info.is_finite() || info <= 0.0 {
804        return Err(GamlssError::RowGeometryUnrepresentable {
805            row,
806            quantity: "Gaussian log-scale Fisher information",
807            eta,
808            value: info,
809        }
810        .into());
811    }
812    let dw = scaled_signed_product3(info, 2.0 * (1.0 - g), d_eta);
813    if !dw.is_finite() {
814        return Err(GamlssError::RowGeometryUnrepresentable {
815            row,
816            quantity: "Gaussian log-scale Fisher-information directional derivative",
817            eta,
818            value: dw,
819        }
820        .into());
821    }
822    Ok(dw)
823}
824
825#[derive(Clone, Copy)]
826pub(crate) struct GaussianDiagonalRowKernel {
827    pub(crate) log_likelihood: f64,
828    pub(crate) location_working_weight: f64,
829    /// The location block's IRLS working response for this row. Under the
830    /// identity location link this is `y`, except on a zero-weight row, where it
831    /// is the row's own location predictor: a zero-weight row must be inert in
832    /// EVERY channel of the working set, not only in its weight. The log-sigma
833    /// channel below has always neutralized this way; the location channel was
834    /// assembled at the call site as `y` wholesale and did not.
835    pub(crate) location_working_response: f64,
836    pub(crate) log_sigma_working_weight: f64,
837    pub(crate) log_sigma_working_response: f64,
838    pub(crate) joint_w: f64,
839    pub(crate) joint_m: f64,
840    pub(crate) joint_n: f64,
841    pub(crate) standardized_residual: f64,
842    pub(crate) inv_sigma: f64,
843    pub(crate) kappa: f64,
844    pub(crate) kappa_prime: f64,
845}
846
847#[inline]
848pub(crate) fn gaussian_diagonal_row_kernel(
849    row: usize,
850    y: f64,
851    location_eta: f64,
852    eta_log_sigma: f64,
853    obs_weight: f64,
854    ln2pi: f64,
855) -> Result<GaussianDiagonalRowKernel, String> {
856    if !y.is_finite() || !location_eta.is_finite() || !eta_log_sigma.is_finite() {
857        return Err(GamlssError::NonFinite {
858            reason: format!(
859                "Gaussian location-scale requires finite row inputs at row {row}: y={y}, eta_mu={location_eta}, eta_log_sigma={eta_log_sigma}"
860            ),
861        }
862        .into());
863    }
864    if !obs_weight.is_finite() || obs_weight < 0.0 {
865        return Err(GamlssError::InvalidInput {
866            reason: format!(
867                "Gaussian location-scale requires finite non-negative weights; weight[{row}]={obs_weight}"
868            ),
869        }
870        .into());
871    }
872    if obs_weight == 0.0 {
873        return Ok(GaussianDiagonalRowKernel {
874            log_likelihood: 0.0,
875            location_working_weight: 0.0,
876            location_working_response: location_eta,
877            log_sigma_working_weight: 0.0,
878            log_sigma_working_response: eta_log_sigma,
879            joint_w: 0.0,
880            joint_m: 0.0,
881            joint_n: 0.0,
882            standardized_residual: 0.0,
883            inv_sigma: 0.0,
884            kappa: 0.0,
885            kappa_prime: 0.0,
886        });
887    }
888
889    // logb noise link σ = b + exp(η) bounds σ ≥ b > 0 by construction, so the
890    // Gaussian location-scale objective ½Σ(y−μ)²/σ² + Σlog σ is bounded below
891    // for any finite data. Its working weight 1/σ² is bounded by 1/b², so
892    // H_μμ has bounded condition number — no after-the-fact floor or cap is
893    // needed (the previous (1e-12, 1e24) clamp was a numerical bandaid for the
894    // pure-exp link's σ→0 singularity and is structurally unnecessary here).
895    // ApproxKind: Exact — working weight analytically bounded in (0, 1/b²].
896    let SigmaJet1 { sigma, d1 } = logb_sigma_jet1_scalar(eta_log_sigma);
897    if !sigma.is_finite() || sigma <= 0.0 {
898        return Err(GamlssError::RowGeometryUnrepresentable {
899            row,
900            quantity: "Gaussian scale link",
901            eta: eta_log_sigma,
902            value: sigma,
903        }
904        .into());
905    }
906    let kappa = logb_dlog_sigma_deta(sigma, d1);
907    if !kappa.is_finite() || kappa <= 0.0 || kappa > 1.0 {
908        return Err(GamlssError::RowGeometryUnrepresentable {
909            row,
910            quantity: "Gaussian log-scale link derivative",
911            eta: eta_log_sigma,
912            value: kappa,
913        }
914        .into());
915    }
916    let inv_sigma = sigma.recip();
917    let location_working_weight =
918        scaled_positive_product_quotient(obs_weight, inv_sigma, inv_sigma, 1.0);
919    if !location_working_weight.is_finite() || location_working_weight <= 0.0 {
920        return Err(GamlssError::RowGeometryUnrepresentable {
921            row,
922            quantity: "Gaussian location Fisher information",
923            eta: location_eta,
924            value: location_working_weight,
925        }
926        .into());
927    }
928
929    // Form (y-mu)/sigma without overflowing the subtraction. Same-sign finite
930    // inputs cannot overflow on subtraction; opposite-sign inputs can, and in
931    // that case separately scaling both terms is exact enough to expose whether
932    // the final quotient is representable.
933    let residual = y - location_eta;
934    let standardized_residual = if residual.is_finite() {
935        residual / sigma
936    } else {
937        y / sigma - location_eta / sigma
938    };
939    let standardized_residual_sq = standardized_residual * standardized_residual;
940    if !standardized_residual.is_finite() || !standardized_residual_sq.is_finite() {
941        return Err(GamlssError::RowGeometryUnrepresentable {
942            row,
943            quantity: "Gaussian standardized residual squared",
944            eta: location_eta,
945            value: standardized_residual_sq,
946        }
947        .into());
948    }
949    let joint_n = if standardized_residual == 0.0 {
950        0.0
951    } else {
952        scaled_positive_product_quotient(
953            obs_weight,
954            standardized_residual.abs(),
955            standardized_residual.abs(),
956            1.0,
957        )
958    };
959    let joint_m = if standardized_residual == 0.0 {
960        0.0
961    } else {
962        scaled_signed_product3(obs_weight, standardized_residual, inv_sigma)
963    };
964    let log_sigma_working_weight = scaled_positive_product_quotient(obs_weight, kappa, kappa, 0.5);
965    if !log_sigma_working_weight.is_finite() || log_sigma_working_weight <= 0.0 {
966        return Err(GamlssError::RowGeometryUnrepresentable {
967            row,
968            quantity: "Gaussian log-scale Fisher information",
969            eta: eta_log_sigma,
970            value: log_sigma_working_weight,
971        }
972        .into());
973    }
974    let log_sigma_step = (standardized_residual_sq - 1.0) / (2.0 * kappa);
975    let log_sigma_working_response = eta_log_sigma + log_sigma_step;
976    if !log_sigma_working_response.is_finite() {
977        return Err(GamlssError::RowGeometryUnrepresentable {
978            row,
979            quantity: "Gaussian log-scale working response",
980            eta: eta_log_sigma,
981            value: log_sigma_working_response,
982        }
983        .into());
984    }
985    let likelihood_core = standardized_residual_sq + ln2pi + 2.0 * sigma.ln();
986    let log_likelihood = if likelihood_core == 0.0 {
987        0.0
988    } else {
989        -scaled_signed_product3(0.5, obs_weight, likelihood_core)
990    };
991    if !log_likelihood.is_finite() || !joint_m.is_finite() || !joint_n.is_finite() {
992        let (quantity, value) = if !log_likelihood.is_finite() {
993            ("Gaussian row log likelihood", log_likelihood)
994        } else if !joint_m.is_finite() {
995            ("Gaussian location score", joint_m)
996        } else {
997            ("Gaussian squared standardized residual weight", joint_n)
998        };
999        return Err(GamlssError::RowGeometryUnrepresentable {
1000            row,
1001            quantity,
1002            eta: eta_log_sigma,
1003            value,
1004        }
1005        .into());
1006    }
1007    let kappa_prime = kappa * (1.0 - kappa);
1008
1009    Ok(GaussianDiagonalRowKernel {
1010        log_likelihood,
1011        location_working_weight,
1012        // Identity location link: z = eta + (y - mu)/g'(mu) = y exactly.
1013        location_working_response: y,
1014        log_sigma_working_weight,
1015        log_sigma_working_response,
1016        joint_w: location_working_weight,
1017        joint_m,
1018        joint_n,
1019        standardized_residual,
1020        inv_sigma,
1021        kappa,
1022        kappa_prime,
1023    })
1024}
1025
1026/// Link identifiers for distribution parameters in multi-parameter GAMLSS families.
1027#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1028pub enum ParameterLink {
1029    Identity,
1030    Log,
1031    Logit,
1032    Probit,
1033    InverseLink,
1034    /// Learnable smooth departure from a known base link.
1035    Wiggle,
1036}