Skip to main content

gam_models/gamlss/gaussian/
location_scale.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
7pub struct GaussianLocationScaleFamily {
8    pub y: Array1<f64>,
9    pub weights: Array1<f64>,
10    pub mu_design: Option<DesignMatrix>,
11    pub log_sigma_design: Option<DesignMatrix>,
12    /// Resource policy threaded into PsiDesignMap construction (and any other
13    /// per-call materialization decision) made during exact-Newton joint psi
14    /// derivative evaluation. Defaults to `ResourcePolicy::default_library()`
15    /// when the family is built without an explicit policy.
16    pub policy: gam_runtime::resource::ResourcePolicy,
17    /// Cached per-observation row scalars keyed by the FULL `(η_μ, η_logσ)`
18    /// predictor pair the scalars were computed at. The row scalars are a
19    /// deterministic function of `(η_μ, η_logσ)` (plus the fixed `y`/`weights`),
20    /// so a hit is only valid when both eta vectors match bit-for-bit element by
21    /// element — a lossy 3-point fingerprint could collide two genuinely
22    /// different predictors and serve STALE scalars, so the key is the whole
23    /// vectors. The compare is O(n), far cheaper than the O(n) transcendental
24    /// recompute it guards, and is hit K+ times per REML gradient/Hessian
25    /// evaluation under the same predictors.
26    pub cached_row_scalars:
27        std::sync::RwLock<Option<(Array1<f64>, Array1<f64>, Arc<GaussianJointRowScalars>)>>,
28}
29
30impl Clone for GaussianLocationScaleFamily {
31    fn clone(&self) -> Self {
32        Self {
33            y: self.y.clone(),
34            weights: self.weights.clone(),
35            mu_design: self.mu_design.clone(),
36            log_sigma_design: self.log_sigma_design.clone(),
37            policy: self.policy.clone(),
38            cached_row_scalars: std::sync::RwLock::new(
39                self.cached_row_scalars
40                    .read()
41                    .expect("lock poisoned")
42                    .clone(),
43            ),
44        }
45    }
46}
47
48impl GaussianLocationScaleFamily {
49    pub const BLOCK_MU: usize = 0;
50    pub const BLOCK_LOG_SIGMA: usize = 1;
51
52    /// Bit-exact equality of two η vectors as a cache key. Within one outer REML
53    /// evaluation η_μ and η_logσ are the fixed inner-converged predictors, so
54    /// every exact-joint consumer (Hessian / directional / second-directional /
55    /// ψ paths) is handed the identical pair; matching the full vectors lets us
56    /// recompute the O(n) transcendental row-scalar stack
57    /// (`gaussian_jointrow_scalars`: one `sigma_link` jet + `exp`/reciprocal per
58    /// row) ONCE and share an `Arc` across all of them — without ever serving a
59    /// stale cache hit to a different predictor. Comparison is on the raw bit
60    /// patterns so a stored entry whose key contains any `NaN` (e.g. a degenerate
61    /// predictor) never spuriously matches a fresh `NaN`-free key and vice versa,
62    /// and `±0.0` are kept distinct; the underlying constructor handles n = 0.
63    #[inline]
64    fn eta_keys_match(stored: &Array1<f64>, query: &Array1<f64>) -> bool {
65        stored.len() == query.len()
66            && stored
67                .iter()
68                .zip(query.iter())
69                .all(|(a, b)| a.to_bits() == b.to_bits())
70    }
71
72    pub(crate) fn get_or_compute_row_scalars(
73        &self,
74        etamu: &Array1<f64>,
75        eta_ls: &Array1<f64>,
76    ) -> Result<Arc<GaussianJointRowScalars>, String> {
77        // Fast path: full-key hit under a shared read lock. A cached entry is
78        // only reused when BOTH eta vectors match the query bit-for-bit, so a
79        // distinct predictor can never be served stale scalars.
80        if let Ok(guard) = self.cached_row_scalars.read() {
81            if let Some((cmu, cls, rows)) = guard.as_ref() {
82                if Self::eta_keys_match(cmu, etamu) && Self::eta_keys_match(cls, eta_ls) {
83                    return Ok(Arc::clone(rows));
84                }
85            }
86        }
87        // Miss: compute once and publish under the write lock. A concurrent
88        // race recomputing the same (η_μ, η_logσ) is harmless — identical
89        // inputs yield bit-identical scalars — so last-writer-wins is safe and
90        // every reader observes equal contents.
91        let rows = Arc::new(gaussian_jointrow_scalars(
92            &self.y,
93            etamu,
94            eta_ls,
95            &self.weights,
96        )?);
97        if let Ok(mut guard) = self.cached_row_scalars.write() {
98            *guard = Some((etamu.clone(), eta_ls.clone(), Arc::clone(&rows)));
99        }
100        Ok(rows)
101    }
102
103    pub fn parameternames() -> &'static [&'static str] {
104        &["mu", "log_sigma"]
105    }
106
107    pub fn parameter_links() -> &'static [ParameterLink] {
108        &[ParameterLink::Identity, ParameterLink::Log]
109    }
110
111    pub fn metadata() -> FamilyMetadata {
112        FamilyMetadata {
113            name: "gaussian_location_scale",
114            parameternames: Self::parameternames(),
115            parameter_links: Self::parameter_links(),
116        }
117    }
118
119    pub(crate) fn exact_joint_supported(&self) -> bool {
120        self.mu_design.is_some() && self.log_sigma_design.is_some()
121    }
122
123    pub(crate) fn exact_block_designs(
124        &self,
125    ) -> Result<(DenseOrOperator<'_>, DenseOrOperator<'_>), String> {
126        let mu_design = self.mu_design.as_ref().ok_or_else(|| {
127            "GaussianLocationScaleFamily exact path is missing mu design".to_string()
128        })?;
129        let log_sigma_design = self.log_sigma_design.as_ref().ok_or_else(|| {
130            "GaussianLocationScaleFamily exact path is missing log-sigma design".to_string()
131        })?;
132        let planned = dense_blocks_planned_budget(&[mu_design, log_sigma_design]);
133        let xmu = dense_block_or_operator(
134            mu_design,
135            mu_design.nrows(),
136            mu_design.ncols(),
137            planned[0],
138            &self.policy,
139        );
140        let x_ls = dense_block_or_operator(
141            log_sigma_design,
142            log_sigma_design.nrows(),
143            log_sigma_design.ncols(),
144            planned[1],
145            &self.policy,
146        );
147        Ok((xmu, x_ls))
148    }
149
150    pub(crate) fn exact_block_designs_fromspecs<'a>(
151        &self,
152        specs: &'a [ParameterBlockSpec],
153    ) -> Result<(DenseOrOperator<'a>, DenseOrOperator<'a>), String> {
154        if specs.len() != 2 {
155            return Err(GamlssError::DimensionMismatch {
156                reason: format!(
157                    "GaussianLocationScaleFamily spec-aware exact path expects 2 specs, got {}",
158                    specs.len()
159                ),
160            }
161            .into());
162        }
163        let mu_design = &specs[Self::BLOCK_MU].design;
164        let log_sigma_design = &specs[Self::BLOCK_LOG_SIGMA].design;
165        let planned = dense_blocks_planned_budget(&[mu_design, log_sigma_design]);
166        let xmu = dense_block_or_operator(
167            mu_design,
168            mu_design.nrows(),
169            mu_design.ncols(),
170            planned[0],
171            &self.policy,
172        );
173        let x_ls = dense_block_or_operator(
174            log_sigma_design,
175            log_sigma_design.nrows(),
176            log_sigma_design.ncols(),
177            planned[1],
178            &self.policy,
179        );
180        Ok((xmu, x_ls))
181    }
182
183    pub(crate) fn exact_joint_block_designs<'a>(
184        &'a self,
185        specs: Option<&'a [ParameterBlockSpec]>,
186    ) -> Result<Option<(DenseOrOperator<'a>, DenseOrOperator<'a>)>, String> {
187        // #1504: prefer the identifiability-CONSTRAINED block designs carried by
188        // `specs` whenever they are provided. The inner joint-Newton solve sizes
189        // its coefficient vector — and the consumer's dense-Hessian `total` — from
190        // these post-audit specs, which can be NARROWER than the family's stored
191        // pre-audit designs: a by-group smooth in BOTH the mean and log-σ blocks
192        // has aliased columns the identifiability audit drops. Reaching for the
193        // stored (unconstrained) designs here regardless of `specs` sized the joint
194        // Hessian to the wider pre-audit width and tripped the dense-Hessian shape
195        // check ("got 36x36, expected 32x32") on every such fit. When no audit
196        // reduction occurred the specs designs equal the stored ones, so ordinary
197        // (non-by-group) gaulss fits are byte-identical; the analytic location-scale
198        // Hessian is design-agnostic, so using the constrained designs loses no
199        // exactness. Fall back to the stored designs only when no specs are given.
200        if let Some(specs) = specs {
201            return self.exact_block_designs_fromspecs(specs).map(Some);
202        }
203        if self.exact_joint_supported() {
204            return self.exact_block_designs().map(Some);
205        }
206        Ok(None)
207    }
208
209    pub(crate) fn exact_joint_dense_block_designs<'a>(
210        &'a self,
211        specs: Option<&'a [ParameterBlockSpec]>,
212    ) -> Result<Option<(Cow<'a, Array2<f64>>, Cow<'a, Array2<f64>>)>, String> {
213        let Some((xmu, x_ls)) = self.exact_joint_block_designs(specs)? else {
214            return Ok(None);
215        };
216        let xmu = match xmu {
217            DenseOrOperator::Borrowed(dense) => Cow::Borrowed(dense),
218            DenseOrOperator::Owned(dense) => Cow::Owned(dense),
219            DenseOrOperator::Operator(_) => {
220                return Err(
221                    "GaussianLocationScaleFamily exact psi path requires chunked operator support for oversized designs"
222                        .to_string(),
223                );
224            }
225        };
226        let x_ls = match x_ls {
227            DenseOrOperator::Borrowed(dense) => Cow::Borrowed(dense),
228            DenseOrOperator::Owned(dense) => Cow::Owned(dense),
229            DenseOrOperator::Operator(_) => {
230                return Err(
231                    "GaussianLocationScaleFamily exact psi path requires chunked operator support for oversized designs"
232                        .to_string(),
233                );
234            }
235        };
236        Ok(Some((xmu, x_ls)))
237    }
238
239    pub(crate) fn exact_newton_joint_hessian_for_specs(
240        &self,
241        block_states: &[ParameterBlockState],
242        specs: Option<&[ParameterBlockSpec]>,
243    ) -> Result<Option<Array2<f64>>, String> {
244        let Some((xmu, x_ls)) = self.exact_joint_block_designs(specs)? else {
245            return Ok(None);
246        };
247        self.exact_newton_joint_hessian_from_designs(block_states, &xmu, &x_ls)
248    }
249
250    /// Exact joint log-likelihood / score in the flattened coefficient space
251    /// `β = [β_μ; β_logσ]`, sized from the (post-audit) `specs` block designs.
252    /// Returns `None` when either block design is unavailable, matching the
253    /// joint-Hessian path's gating so the two never disagree on shape.
254    pub(crate) fn exact_newton_joint_gradient_for_specs(
255        &self,
256        block_states: &[ParameterBlockState],
257        specs: Option<&[ParameterBlockSpec]>,
258    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
259        let Some((xmu, x_ls)) = self.exact_joint_dense_block_designs(specs)? else {
260            return Ok(None);
261        };
262        self.exact_newton_joint_gradient_from_designs(block_states, &xmu, &x_ls)
263            .map(Some)
264    }
265
266    /// Assemble the joint log-likelihood gradient `g = ∇_β log L` from the SAME
267    /// per-row score the inner-solve / ψ path consumes
268    /// (`gaussian_joint_psi_firstweights.{scoremu, score_ls}`), so the analytic
269    /// joint gradient can never disagree with the derivation feeding the joint
270    /// Hessian. Those fields are the negative-log-likelihood (objective) score
271    /// w.r.t. η (`scoremu = -m`, `score_ls = κ(a−n)`); the log-likelihood
272    /// gradient is their negation contracted with the block designs,
273    /// `g = [Xμᵀ·(−scoreμ); X_lsᵀ·(−score_ls)]`. The ψ-direction inputs only
274    /// feed the drift / objective fields, not `scoremu`/`score_ls`, so passing
275    /// zero directions yields exactly the plain per-row score.
276    pub(crate) fn exact_newton_joint_gradient_from_designs(
277        &self,
278        block_states: &[ParameterBlockState],
279        xmu: &Array2<f64>,
280        x_ls: &Array2<f64>,
281    ) -> Result<ExactNewtonJointGradientEvaluation, String> {
282        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
283        let n = self.y.len();
284        let etamu = &block_states[Self::BLOCK_MU].eta;
285        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
286        if etamu.len() != n
287            || eta_ls.len() != n
288            || self.weights.len() != n
289            || xmu.nrows() != n
290            || x_ls.nrows() != n
291        {
292            return Err(GamlssError::DimensionMismatch {
293                reason: "GaussianLocationScaleFamily joint gradient input size mismatch"
294                    .to_string(),
295            }
296            .into());
297        }
298        let rows = self.get_or_compute_row_scalars(etamu, eta_ls)?;
299        let zero = Array1::<f64>::zeros(n);
300        let weights = gaussian_joint_psi_firstweights(&rows, &zero, &zero);
301        let grad_eta_mu = -&weights.scoremu;
302        let grad_eta_ls = -&weights.score_ls;
303        let grad_mu = fast_atv(xmu, &grad_eta_mu);
304        let grad_ls = fast_atv(x_ls, &grad_eta_ls);
305        let gradient = gaussian_pack_joint_score(&grad_mu, &grad_ls);
306        let log_likelihood = self.log_likelihood_only(block_states)?;
307        Ok(ExactNewtonJointGradientEvaluation {
308            log_likelihood,
309            gradient,
310        })
311    }
312
313    pub(crate) fn exact_newton_joint_hessian_directional_derivative_for_specs(
314        &self,
315        block_states: &[ParameterBlockState],
316        specs: Option<&[ParameterBlockSpec]>,
317        d_beta_flat: &Array1<f64>,
318    ) -> Result<Option<Array2<f64>>, String> {
319        let Some((xmu, x_ls)) = self.exact_joint_block_designs(specs)? else {
320            return Ok(None);
321        };
322        self.exact_newton_joint_hessian_directional_derivative_from_designs(
323            block_states,
324            &xmu,
325            &x_ls,
326            d_beta_flat,
327        )
328    }
329
330    pub(crate) fn exact_newton_joint_hessian_second_directional_derivative_for_specs(
331        &self,
332        block_states: &[ParameterBlockState],
333        specs: Option<&[ParameterBlockSpec]>,
334        d_beta_u_flat: &Array1<f64>,
335        d_betav_flat: &Array1<f64>,
336    ) -> Result<Option<Array2<f64>>, String> {
337        let Some((xmu, x_ls)) = self.exact_joint_block_designs(specs)? else {
338            return Ok(None);
339        };
340        self.exact_newton_joint_hessiansecond_directional_derivative_from_designs(
341            block_states,
342            &xmu,
343            &x_ls,
344            d_beta_u_flat,
345            d_betav_flat,
346        )
347    }
348
349    pub(crate) fn exact_newton_joint_psi_terms_for_specs(
350        &self,
351        block_states: &[ParameterBlockState],
352        specs: &[ParameterBlockSpec],
353        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
354        psi_index: usize,
355    ) -> Result<Option<gam_problem::ExactNewtonJointPsiTerms>, String> {
356        let Some((xmu, x_ls)) = self.exact_joint_dense_block_designs(Some(specs))? else {
357            return Ok(None);
358        };
359        self.exact_newton_joint_psi_terms_from_designs(
360            block_states,
361            specs,
362            derivative_blocks,
363            psi_index,
364            &xmu,
365            &x_ls,
366        )
367    }
368
369    pub(crate) fn exact_newton_joint_psisecond_order_terms_for_specs(
370        &self,
371        block_states: &[ParameterBlockState],
372        specs: &[ParameterBlockSpec],
373        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
374        psi_i: usize,
375        psi_j: usize,
376    ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderTerms>, String> {
377        let Some((xmu, x_ls)) = self.exact_joint_dense_block_designs(Some(specs))? else {
378            return Ok(None);
379        };
380        self.exact_newton_joint_psisecond_order_terms_from_designs(
381            block_states,
382            derivative_blocks,
383            psi_i,
384            psi_j,
385            &xmu,
386            &x_ls,
387        )
388    }
389
390    pub(crate) fn exact_newton_joint_psihessian_directional_derivative_for_specs(
391        &self,
392        block_states: &[ParameterBlockState],
393        specs: &[ParameterBlockSpec],
394        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
395        psi_index: usize,
396        d_beta_flat: &Array1<f64>,
397    ) -> Result<Option<Array2<f64>>, String> {
398        let Some((xmu, x_ls)) = self.exact_joint_dense_block_designs(Some(specs))? else {
399            return Ok(None);
400        };
401        self.exact_newton_joint_psihessian_directional_derivative_from_designs(
402            block_states,
403            derivative_blocks,
404            psi_index,
405            d_beta_flat,
406            &xmu,
407            &x_ls,
408        )
409    }
410
411    pub(crate) fn exact_newton_joint_hessian_from_designs(
412        &self,
413        block_states: &[ParameterBlockState],
414        xmu: &DenseOrOperator<'_>,
415        x_ls: &DenseOrOperator<'_>,
416    ) -> Result<Option<Array2<f64>>, String> {
417        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
418        let n = self.y.len();
419        let etamu = &block_states[Self::BLOCK_MU].eta;
420        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
421        if etamu.len() != n || eta_ls.len() != n || self.weights.len() != n {
422            return Err(GamlssError::DimensionMismatch {
423                reason: "GaussianLocationScaleFamily input size mismatch".to_string(),
424            }
425            .into());
426        }
427
428        let rows = self.get_or_compute_row_scalars(etamu, eta_ls)?;
429        // Observed joint Hessian (Wood–Pya–Säfken 2016 LAML object; #1561):
430        // mm = w, ml = 2κm, ll = κ'(a−n)+2κ²n. Shared single-source-of-truth
431        // constructor so this dense path and the matrix-free workspace can never
432        // disagree on the cross block. See `gaussian_locscale_observed_joint_row_coeffs`.
433        let (mm, cross, scale) = gaussian_locscale_observed_joint_row_coeffs(&rows);
434        Ok(Some(gaussian_joint_hessian_from_designs(
435            xmu, x_ls, &mm, &cross, &scale,
436        )?))
437    }
438
439    pub(crate) fn exact_newton_joint_hessian_directional_derivative_from_designs(
440        &self,
441        block_states: &[ParameterBlockState],
442        xmu: &DenseOrOperator<'_>,
443        x_ls: &DenseOrOperator<'_>,
444        d_beta_flat: &Array1<f64>,
445    ) -> Result<Option<Array2<f64>>, String> {
446        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
447        let n = self.y.len();
448        let etamu = &block_states[Self::BLOCK_MU].eta;
449        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
450        if etamu.len() != n || eta_ls.len() != n || self.weights.len() != n {
451            return Err(GamlssError::DimensionMismatch {
452                reason: "GaussianLocationScaleFamily input size mismatch".to_string(),
453            }
454            .into());
455        }
456
457        let pmu = xmu.ncols();
458        let p_ls = x_ls.ncols();
459        let total = pmu + p_ls;
460        if d_beta_flat.len() != total {
461            return Err(GamlssError::DimensionMismatch {
462                reason: format!(
463                    "GaussianLocationScaleFamily joint d_beta length mismatch: got {}, expected {}",
464                    d_beta_flat.len(),
465                    total
466                ),
467            }
468            .into());
469        }
470        let ximu = xmu.dot(d_beta_flat.slice(s![0..pmu]));
471        let xi_ls = x_ls.dot(d_beta_flat.slice(s![pmu..pmu + p_ls]));
472        let rows = self.get_or_compute_row_scalars(etamu, eta_ls)?;
473        let directional = gaussian_joint_first_directionalweights(&rows, &ximu, &xi_ls);
474        let dhmumu = directional.0;
475        let dh_ls_ls = directional.2;
476        // Observed cross block H_{μ,ls} = 2κm is nonzero away from the truth
477        // (the value Hessian carries it; see
478        // exact_newton_joint_hessian_from_designs / #1561), so its directional
479        // derivative d(2κm)[ξ] = −2κw·ξ_μ + (2κ'−4κ²)m·ξ_s is nonzero too. Use
480        // the computed observed-cross channel (`directional.1`) so the Hessian's
481        // derivative and its value are the SAME functional at every order (no
482        // objective↔gradient desync feeding the outer criterion).
483        let dhmu_ls = directional.1;
484
485        Ok(Some(gaussian_joint_hessian_from_designs(
486            xmu, x_ls, &dhmumu, &dhmu_ls, &dh_ls_ls,
487        )?))
488    }
489
490    pub(crate) fn exact_newton_joint_hessiansecond_directional_derivative_from_designs(
491        &self,
492        block_states: &[ParameterBlockState],
493        xmu: &DenseOrOperator<'_>,
494        x_ls: &DenseOrOperator<'_>,
495        d_beta_u_flat: &Array1<f64>,
496        d_betav_flat: &Array1<f64>,
497    ) -> Result<Option<Array2<f64>>, String> {
498        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
499        let n = self.y.len();
500        let etamu = &block_states[Self::BLOCK_MU].eta;
501        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
502        if etamu.len() != n || eta_ls.len() != n || self.weights.len() != n {
503            return Err(GamlssError::DimensionMismatch {
504                reason: "GaussianLocationScaleFamily input size mismatch".to_string(),
505            }
506            .into());
507        }
508
509        let pmu = xmu.ncols();
510        let p_ls = x_ls.ncols();
511        let total = pmu + p_ls;
512        if d_beta_u_flat.len() != total || d_betav_flat.len() != total {
513            return Err(GamlssError::DimensionMismatch { reason: format!(
514                "GaussianLocationScaleFamily joint second directional derivative length mismatch: got {} and {}, expected {}",
515                d_beta_u_flat.len(),
516                d_betav_flat.len(),
517                total
518            ) }.into());
519        }
520        let ximu_u = xmu.dot(d_beta_u_flat.slice(s![0..pmu]));
521        let xi_ls_u = x_ls.dot(d_beta_u_flat.slice(s![pmu..pmu + p_ls]));
522        let ximuv = xmu.dot(d_betav_flat.slice(s![0..pmu]));
523        let xi_lsv = x_ls.dot(d_betav_flat.slice(s![pmu..pmu + p_ls]));
524        let rows = self.get_or_compute_row_scalars(etamu, eta_ls)?;
525        let second =
526            gaussian_jointsecond_directionalweights(&rows, &ximu_u, &xi_ls_u, &ximuv, &xi_lsv);
527        let d2hmumu = second.0;
528        let d2h_ls_ls = second.2;
529        // Observed cross block H_{μ,ls} = 2κm (see
530        // exact_newton_joint_hessian_from_designs / #1561); its second
531        // directional derivative d²(2κm)[u,v] (`second.1`) is nonzero and must
532        // be assembled so the value and its second derivative are the SAME
533        // functional at every order.
534        let d2hmu_ls = second.1;
535
536        Ok(Some(gaussian_joint_hessian_from_designs(
537            xmu, x_ls, &d2hmumu, &d2hmu_ls, &d2h_ls_ls,
538        )?))
539    }
540
541    pub(crate) fn exact_newton_joint_psi_direction(
542        &self,
543        block_states: &[ParameterBlockState],
544        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
545        psi_index: usize,
546        xmu: &Array2<f64>,
547        x_ls: &Array2<f64>,
548        policy: &gam_runtime::resource::ResourcePolicy,
549    ) -> Result<Option<LocationScaleJointPsiDirection>, String> {
550        let Some(parts) = locscale_joint_psi_direction_parts(
551            block_states,
552            derivative_blocks,
553            psi_index,
554            self.y.len(),
555            xmu.ncols(),
556            x_ls.ncols(),
557            Self::BLOCK_MU,
558            Self::BLOCK_LOG_SIGMA,
559            2,
560            "GaussianLocationScaleFamily",
561            "mu",
562            policy,
563        )?
564        else {
565            return Ok(None);
566        };
567        Ok(Some(LocationScaleJointPsiDirection {
568            block_idx: parts.block_idx,
569            local_idx: parts.local_idx,
570            z_primary_psi: parts.primary_z,
571            z_ls_psi: parts.log_sigma_z,
572            x_primary_psi: parts.primary_psi,
573            x_ls_psi: parts.log_sigma_psi,
574        }))
575    }
576
577    pub(crate) fn exact_newton_joint_psisecond_design_drifts(
578        &self,
579        block_states: &[ParameterBlockState],
580        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
581        psi_a: &LocationScaleJointPsiDirection,
582        psi_b: &LocationScaleJointPsiDirection,
583        xmu: &Array2<f64>,
584        x_ls: &Array2<f64>,
585    ) -> Result<LocationScaleJointPsiSecondDrifts, String> {
586        locscale_joint_psisecond_design_drifts(
587            block_states,
588            derivative_blocks,
589            psi_a,
590            psi_b,
591            LocScalePsiDriftConfig {
592                n: self.y.len(),
593                p_primary: xmu.ncols(),
594                p_log_sigma: x_ls.ncols(),
595                primary_block_idx: Self::BLOCK_MU,
596                log_sigma_block_idx: Self::BLOCK_LOG_SIGMA,
597                family_name: "GaussianLocationScaleFamily",
598                primary_label: "mu",
599                policy: &self.policy,
600            },
601        )
602    }
603
604    pub(crate) fn exact_newton_joint_psi_terms_from_designs(
605        &self,
606        block_states: &[ParameterBlockState],
607        specs: &[ParameterBlockSpec],
608        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
609        psi_index: usize,
610        xmu: &Array2<f64>,
611        x_ls: &Array2<f64>,
612    ) -> Result<Option<gam_problem::ExactNewtonJointPsiTerms>, String> {
613        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
614        if specs.len() != 2 || derivative_blocks.len() != 2 {
615            return Err(GamlssError::DimensionMismatch { reason: format!(
616                "GaussianLocationScaleFamily joint psi terms expect 2 specs and 2 derivative blocks, got {} and {}",
617                specs.len(),
618                derivative_blocks.len()
619            ) }.into());
620        }
621        let Some(dir_a) = self.exact_newton_joint_psi_direction(
622            block_states,
623            derivative_blocks,
624            psi_index,
625            xmu,
626            x_ls,
627            &self.policy,
628        )?
629        else {
630            return Ok(None);
631        };
632        // Gaussian 2-block location-scale family in the unified flattened
633        // coefficient space beta = [betamu; beta_sigma]:
634        //
635        //   mu_i = z_i^T betamu,
636        //   ell_i = x_i^T beta_sigma,
637        //   s_i = exp(ell_i),
638        //   r_i = y_i - mu_i,
639        //   q_i = r_i / s_i,
640        //   w_i = s_i^{-2},
641        //   alpha_i = r_i s_i^{-2},
642        //   b_i = q_i^2.
643        //
644        // The first fixed-beta psi object returned here is likelihood-only:
645        //
646        //   D_a         = -alpha^T m_a + (1 - b)^T ell_a
647        //   D_{beta a}  = [ -Xmu^T alpha_a - X_{mu,a}^T alpha ;
648        //                   -X_sigma^T b_a + X_{sigma,a}^T (1-b) ]
649        //   D_{bb a}    = [ Xmu^T W_a Xmu + X_{mu,a}^T W Xmu + Xmu^T W X_{mu,a},
650        //                   2( Xmu^T A_a X_sigma + X_{mu,a}^T A X_sigma + Xmu^T A X_{sigma,a} );
651        //                   sym,
652        //                   2( X_sigma^T B_a X_sigma + X_{sigma,a}^T B X_sigma + X_sigma^T B X_{sigma,a} ) ]
653        //
654        // with m_a = X_{mu,a} betamu, ell_a = X_{sigma,a} beta_sigma and
655        // rowwise scalar drifts
656        //
657        //   w_a     = -2 w * ell_a
658        //   alpha_a = -w * m_a - 2 alpha * ell_a
659        //   b_a     = -2 alpha * m_a - 2 b * ell_a.
660        //
661        // Generic code in custom_family.rs promotes these likelihood-only
662        // objects to the full fixed-beta V_a / g_a / H_a by adding S_a.
663        let etamu = &block_states[Self::BLOCK_MU].eta;
664        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
665        let rows = self.get_or_compute_row_scalars(etamu, eta_ls)?;
666        let weights_a =
667            gaussian_joint_psi_firstweights(&rows, &dir_a.z_primary_psi, &dir_a.z_ls_psi);
668        let objective_psi = weights_a.objective_psirow.sum();
669        let xmu_map = dir_a.x_primary_psi.as_linear_map_ref();
670        let x_ls_map = dir_a.x_ls_psi.as_linear_map_ref();
671        let score_mu =
672            xmu_map.transpose_mul(weights_a.scoremu.view()) + fast_atv(xmu, &weights_a.dscoremu);
673        let score_ls = x_ls_map.transpose_mul(weights_a.score_ls.view())
674            + fast_atv(x_ls, &weights_a.dscore_ls);
675        let score_psi = gaussian_pack_joint_score(&score_mu, &score_ls);
676        let hessian_psi_operator = build_two_block_custom_family_joint_psi_operator_from_actions(
677            dir_a.x_primary_psi.cloned_first_action(),
678            dir_a.x_ls_psi.cloned_first_action(),
679            0..xmu.ncols(),
680            xmu.ncols()..xmu.ncols() + x_ls.ncols(),
681            xmu,
682            x_ls,
683            &weights_a.hmumu,
684            &weights_a.hmu_ls,
685            &weights_a.h_ls_ls,
686            &weights_a.dhmumu,
687            &weights_a.dhmu_ls,
688            &weights_a.dh_ls_ls,
689        )?;
690        let hessian_psi = if hessian_psi_operator.is_some() {
691            Array2::zeros((0, 0))
692        } else {
693            gaussian_joint_psihessian_fromweights(xmu, x_ls, xmu_map, x_ls_map, &weights_a)?
694        };
695
696        Ok(Some(gam_problem::ExactNewtonJointPsiTerms {
697            objective_psi,
698            score_psi,
699            hessian_psi,
700            hessian_psi_operator,
701        }))
702    }
703
704    pub(crate) fn exact_newton_joint_psisecond_order_terms_from_designs(
705        &self,
706        block_states: &[ParameterBlockState],
707        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
708        psi_i: usize,
709        psi_j: usize,
710        xmu: &Array2<f64>,
711        x_ls: &Array2<f64>,
712    ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderTerms>, String> {
713        let Some(dir_i) = self.exact_newton_joint_psi_direction(
714            block_states,
715            derivative_blocks,
716            psi_i,
717            xmu,
718            x_ls,
719            &self.policy,
720        )?
721        else {
722            return Ok(None);
723        };
724        let Some(dir_j) = self.exact_newton_joint_psi_direction(
725            block_states,
726            derivative_blocks,
727            psi_j,
728            xmu,
729            x_ls,
730            &self.policy,
731        )?
732        else {
733            return Ok(None);
734        };
735        Ok(Some(
736            self.exact_newton_joint_psisecond_order_terms_from_parts(
737                block_states,
738                derivative_blocks,
739                &dir_i,
740                &dir_j,
741                xmu,
742                x_ls,
743                None,
744            )?,
745        ))
746    }
747
748    pub(crate) fn exact_newton_joint_psisecond_order_terms_from_parts(
749        &self,
750        block_states: &[ParameterBlockState],
751        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
752        dir_i: &LocationScaleJointPsiDirection,
753        dir_j: &LocationScaleJointPsiDirection,
754        xmu: &Array2<f64>,
755        x_ls: &Array2<f64>,
756        subsample: Option<&[crate::outer_subsample::WeightedOuterRow]>,
757    ) -> Result<gam_problem::ExactNewtonJointPsiSecondOrderTerms, String> {
758        let second_drifts = self.exact_newton_joint_psisecond_design_drifts(
759            block_states,
760            derivative_blocks,
761            dir_i,
762            dir_j,
763            xmu,
764            x_ls,
765        )?;
766        let n = self.y.len();
767        let xmu_i_map = dir_i.x_primary_psi.as_linear_map_ref();
768        let x_ls_i_map = dir_i.x_ls_psi.as_linear_map_ref();
769        let xmu_j_map = dir_j.x_primary_psi.as_linear_map_ref();
770        let x_ls_j_map = dir_j.x_ls_psi.as_linear_map_ref();
771        let xmu_ab_map = second_psi_linear_map(
772            second_drifts.x_primary_ab_action.as_ref(),
773            second_drifts.x_primary_ab.as_ref(),
774            n,
775            xmu.ncols(),
776        );
777        let x_ls_ab_map = second_psi_linear_map(
778            second_drifts.x_ls_ab_action.as_ref(),
779            second_drifts.x_ls_ab.as_ref(),
780            n,
781            x_ls.ncols(),
782        );
783        // Second fixed-beta psi objects for the same Gaussian location-scale
784        // kernel. Using the notation from the first-order comment, the rowwise
785        // second psi drifts are
786        //
787        //   w_ab     = 4 w * ell_a * ell_b - 2 w * ell_ab
788        //   alpha_ab = 2 w * (m_a * ell_b + m_b * ell_a)
789        //              + 4 alpha * ell_a * ell_b
790        //              - w * m_ab
791        //              - 2 alpha * ell_ab
792        //   b_ab     = 2 w * m_a * m_b
793        //              + 4 alpha * (m_a * ell_b + m_b * ell_a)
794        //              + 4 b * ell_a * ell_b
795        //              - 2 alpha * m_ab
796        //              - 2 b * ell_ab.
797        //
798        // The exact likelihood-only second-order objects are then:
799        //
800        //   D_ab,
801        //   D_{beta ab},
802        //   D_{beta beta ab},
803        //
804        // assembled from the usual product-rule expansion over realized
805        // design motion X_{.,a}, X_{.,b}, X_{.,ab}. Generic code adds S_ab.
806        let etamu = &block_states[Self::BLOCK_MU].eta;
807        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
808        let rows = self.get_or_compute_row_scalars(etamu, eta_ls)?;
809        let mut weights_i =
810            gaussian_joint_psi_firstweights(&rows, &dir_i.z_primary_psi, &dir_i.z_ls_psi);
811        let mut weights_j =
812            gaussian_joint_psi_firstweights(&rows, &dir_j.z_primary_psi, &dir_j.z_ls_psi);
813        let mut secondweights = gaussian_joint_psisecondweights(
814            &rows,
815            &dir_i.z_primary_psi,
816            &dir_i.z_ls_psi,
817            &dir_j.z_primary_psi,
818            &dir_j.z_ls_psi,
819            &second_drifts.z_primary_ab,
820            &second_drifts.z_ls_ab,
821        );
822        if let Some(sub_rows) = subsample {
823            // HT mask: every downstream consumer (gaussian_joint_psisecondhessian_fromweights,
824            // weighted_crossprod_psi_maps with weights_*.{hmumu,hmu_ls,h_ls_ls},
825            // fast_atv on d2score_* and dscore_*) is row-linear in these arrays, so
826            // scaling sampled rows by 1/π_i and zeroing the rest yields an unbiased
827            // estimator of the full-data second-order ψ Hessian and ψ score.
828            apply_ht_mask_first(&mut weights_i, sub_rows);
829            apply_ht_mask_first(&mut weights_j, sub_rows);
830            apply_ht_mask_second(&mut secondweights, sub_rows);
831        }
832        let objective_psi_psi = secondweights.objective_psi_psirow.sum();
833
834        let score_psi_psi = gaussian_pack_joint_score(
835            &(xmu_ab_map.transpose_mul(weights_i.scoremu.view())
836                + xmu_i_map.transpose_mul(weights_j.dscoremu.view())
837                + xmu_j_map.transpose_mul(weights_i.dscoremu.view())
838                + fast_atv(xmu, &secondweights.d2scoremu)),
839            &(x_ls_ab_map.transpose_mul(weights_i.score_ls.view())
840                + x_ls_i_map.transpose_mul(weights_j.dscore_ls.view())
841                + x_ls_j_map.transpose_mul(weights_i.dscore_ls.view())
842                + fast_atv(x_ls, &secondweights.d2score_ls)),
843        );
844        let hessian_psi_psi = gaussian_joint_psisecondhessian_fromweights(
845            xmu,
846            x_ls,
847            xmu_i_map,
848            x_ls_i_map,
849            xmu_j_map,
850            x_ls_j_map,
851            xmu_ab_map,
852            x_ls_ab_map,
853            &weights_i,
854            &weights_j,
855            &secondweights,
856        )?;
857
858        Ok(gam_problem::ExactNewtonJointPsiSecondOrderTerms {
859            objective_psi_psi,
860            score_psi_psi,
861            hessian_psi_psi,
862            hessian_psi_psi_operator: None,
863        })
864    }
865
866    pub(crate) fn exact_newton_joint_psihessian_directional_derivative_from_designs(
867        &self,
868        block_states: &[ParameterBlockState],
869        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
870        psi_index: usize,
871        d_beta_flat: &Array1<f64>,
872        xmu: &Array2<f64>,
873        x_ls: &Array2<f64>,
874    ) -> Result<Option<Array2<f64>>, String> {
875        let Some(dir_a) = self.exact_newton_joint_psi_direction(
876            block_states,
877            derivative_blocks,
878            psi_index,
879            xmu,
880            x_ls,
881            &self.policy,
882        )?
883        else {
884            return Ok(None);
885        };
886        Ok(Some(
887            self.exact_newton_joint_psihessian_directional_derivative_from_parts(
888                block_states,
889                &dir_a,
890                d_beta_flat,
891                xmu,
892                x_ls,
893                None,
894            )?,
895        ))
896    }
897
898    pub(crate) fn exact_newton_joint_psihessian_directional_derivative_from_parts(
899        &self,
900        block_states: &[ParameterBlockState],
901        dir_a: &LocationScaleJointPsiDirection,
902        d_beta_flat: &Array1<f64>,
903        xmu: &Array2<f64>,
904        x_ls: &Array2<f64>,
905        subsample: Option<&[crate::outer_subsample::WeightedOuterRow]>,
906    ) -> Result<Array2<f64>, String> {
907        let etamu = &block_states[Self::BLOCK_MU].eta;
908        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
909        let pmu = xmu.ncols();
910        let p_ls = x_ls.ncols();
911        let xmu_map = dir_a.x_primary_psi.as_linear_map_ref();
912        let x_ls_map = dir_a.x_ls_psi.as_linear_map_ref();
913        let total = pmu + p_ls;
914        if d_beta_flat.len() != total {
915            return Err(GamlssError::DimensionMismatch { reason: format!(
916                "GaussianLocationScaleFamily joint psi hessian directional derivative length mismatch: got {}, expected {}",
917                d_beta_flat.len(),
918                total
919            ) }.into());
920        }
921        // Both channels enter the OBSERVED mixed drift (#1561): the cross block
922        // H_{μ,ls}=2κm and the observed h_ls_ls depend on the μ-channel drift
923        // (xi_mu = Xmu·u_mu), the ψ μ-direction (dir_a.z_primary_psi), and the
924        // mixed μ direction-curvature (uza_mu = (dXmu/dψ)·u_mu).
925        let u_mu = d_beta_flat.slice(s![0..pmu]);
926        let u_ls = d_beta_flat.slice(s![pmu..pmu + p_ls]);
927        let xi_mu = fast_av(xmu, &u_mu);
928        let xi_ls = fast_av(x_ls, &u_ls);
929        let uza_mu = xmu_map.forward_mul(u_mu);
930        let uza_ls = x_ls_map.forward_mul(u_ls);
931        // Mixed drift T_a[u] = D_beta H_a^{(D)}[u] for the Gaussian family.
932        //
933        // Along u = [umu; u_sigma], define xi = Xmu umu and zeta = X_sigma u_sigma.
934        // The first beta-directional drifts of the Gaussian row scalars are
935        //
936        //   d_u w     = -2 w * zeta
937        //   d_u alpha = -w * xi - 2 alpha * zeta
938        //   d_u b     = -2 alpha * xi - 2 b * zeta.
939        //
940        // Differentiating the psi-a scalar drifts once more gives
941        //
942        //   d_u w_a     = 4 w * ell_a * zeta - 2 w * zeta_a
943        //   d_u alpha_a = 2 w * (m_a * zeta + ell_a * xi)
944        //                 - w * xi_a
945        //                 + 4 alpha * ell_a * zeta
946        //                 - 2 alpha * zeta_a
947        //   d_u b_a     = 2 w * m_a * xi
948        //                 + 4 alpha * (m_a * zeta + ell_a * xi)
949        //                 + 4 b * ell_a * zeta
950        //                 - 2 alpha * xi_a
951        //                 - 2 b * zeta_a.
952        //
953        // The matrix drift returned here is the exact likelihood-only
954        //
955        //   T_a[u] = D_beta H_{psi_a}^{(D)}[u],
956        //
957        // assembled blockwise as
958        //
959        //   Kmumu,a[u]   = Xmu^T W_a[u] Xmu
960        //                   + X_{mu,a}^T W[u] Xmu
961        //                   + Xmu^T W[u] X_{mu,a}
962        //   Kmusigma,a[u]= 2( Xmu^T A_a[u] X_sigma
963        //                   + X_{mu,a}^T A[u] X_sigma
964        //                   + Xmu^T A[u] X_{sigma,a} )
965        //   K_sigmasigma,a[u]
966        //                   = 2( X_sigma^T B_a[u] X_sigma
967        //                   + X_{sigma,a}^T B[u] X_sigma
968        //                   + X_sigma^T B[u] X_{sigma,a} ).
969        //
970        // Generic code then combines this with S(theta)-motion and the profile
971        // mode responses to form ddot H_{ij}.
972        let rows = self.get_or_compute_row_scalars(etamu, eta_ls)?;
973        let mut mixedweights = gaussian_joint_psi_mixed_driftweights(
974            &rows,
975            &xi_mu,
976            &xi_ls,
977            &dir_a.z_primary_psi,
978            &dir_a.z_ls_psi,
979            &uza_mu,
980            &uza_ls,
981        );
982        if let Some(sub_rows) = subsample {
983            // HT mask: `gaussian_joint_psi_mixedhessian_drift_fromweights` is
984            // row-linear in every `mixedweights.*` array via `xt_diag_*_dense`
985            // and `weighted_crossprod_psi_maps`, so the masked Hessian-drift
986            // remains an unbiased estimator of the full-data drift.
987            apply_ht_mask_mixed(&mut mixedweights, sub_rows);
988        }
989
990        gaussian_joint_psi_mixedhessian_drift_fromweights(
991            xmu,
992            x_ls,
993            xmu_map,
994            x_ls_map,
995            &mixedweights,
996        )
997    }
998
999    /// Build the [`BlockEffectiveJacobian`] for block `block_idx` given the
1000    /// realised block specs.  Returns an [`AdditiveBlockJacobian`] encoding the
1001    /// linear map η_r[i] = X_r[i,:] · β_r:
1002    ///
1003    /// - block 0 (mu):       output 0 = design rows, output 1 = zeros
1004    /// - block 1 (log_sigma): output 0 = zeros, output 1 = design rows
1005    pub fn block_effective_jacobian(
1006        specs: &[ParameterBlockSpec],
1007        block_idx: usize,
1008    ) -> Result<Box<dyn BlockEffectiveJacobian>, String> {
1009        crate::block_layout::block_jacobian::AdditiveWiggleBlockLayout {
1010            family: "GaussianLocationScaleFamily",
1011            n_outputs: 2,
1012            additive_blocks: &[Self::BLOCK_MU, Self::BLOCK_LOG_SIGMA],
1013            wiggle_block: None,
1014        }
1015        .block_effective_jacobian(specs, block_idx)
1016    }
1017}
1018
1019/// Per-subject 2×2 channel Hessian `W_i` for Gaussian location-scale.
1020///
1021/// The row negative log-likelihood (with per-row weight `w_i`, response `y_i`,
1022/// mean predictor `μ_i`, log-scale predictor `s_i = log σ_i`) is
1023///
1024/// ```text
1025/// ρ_i(μ, s) = w_i [s + 0.5·(y_i − μ)²·exp(−2s)]
1026/// ```
1027///
1028/// The 2×2 Hessian in `(μ, s)` coordinates:
1029///
1030/// ```text
1031/// W_i[0,0] = w_i · exp(−2 s_i)                        ∂²ρ/∂μ²
1032/// W_i[1,1] = w_i · 2·(y_i − μ_i)²·exp(−2 s_i)        ∂²ρ/∂s²
1033/// W_i[0,1] = W_i[1,0] = w_i · 2·(y_i − μ_i)·exp(−2 s_i)  ∂²ρ/∂μ∂s
1034/// ```
1035///
1036/// The off-diagonal cross-channel term `∂²ρ/∂μ∂s` is nonzero whenever the
1037/// residual `(y_i − μ_i) ≠ 0`, i.e. away from the fitted mean.
1038pub struct GaussianLocationScaleChannelHessian {
1039    /// Row-major `(n × 2 × 2)` PSD-clamped per-subject Hessian.
1040    pub(crate) h: ndarray::Array3<f64>,
1041}
1042
1043impl GaussianLocationScaleChannelHessian {
1044    /// Construct the raw (un-PSD-clamped) per-subject observed Hessian.
1045    ///
1046    /// For Gaussian location-scale the 2×2 observed Hessian
1047    /// `[[w·e^{-2s}, 2·w·r·e^{-2s}], [2·w·r·e^{-2s}, 2·w·r²·e^{-2s}]]`
1048    /// has determinant `-2·w²·r²·e^{-4s}` which is non-positive whenever
1049    /// the residual `r = y − μ ≠ 0`. Tests that finite-difference the row
1050    /// NLL must compare against this raw observed Hessian — PSD clamping
1051    /// alters the eigenvalues and the FD-versus-closed-form match fails.
1052    ///
1053    /// Production code that needs a PSD matrix (e.g. the canonicalize gate)
1054    /// must call [`Self::from_pilot`] which PSD-clamps via 2×2
1055    /// eigendecomposition.
1056    pub fn from_pilot_observed_unclamped(
1057        y: &ndarray::Array1<f64>,
1058        w: &ndarray::Array1<f64>,
1059        eta_mu: &ndarray::Array1<f64>,
1060        eta_log_sigma: &ndarray::Array1<f64>,
1061    ) -> Result<Self, String> {
1062        let n = y.len();
1063        if w.len() != n || eta_mu.len() != n || eta_log_sigma.len() != n {
1064            return Err(format!(
1065                "GaussianLocationScaleChannelHessian::from_pilot_observed_unclamped: \
1066                 length mismatch y={n} w={} eta_mu={} eta_log_sigma={}",
1067                w.len(),
1068                eta_mu.len(),
1069                eta_log_sigma.len(),
1070            ));
1071        }
1072        let mut h = ndarray::Array3::<f64>::zeros((n, 2, 2));
1073        for i in 0..n {
1074            let wi = w[i];
1075            let mu_i = eta_mu[i];
1076            let s_i = eta_log_sigma[i];
1077            let inv_sigma2 = (-2.0 * s_i).exp();
1078            let resid = y[i] - mu_i;
1079            h[[i, 0, 0]] = wi * inv_sigma2;
1080            h[[i, 1, 1]] = wi * 2.0 * resid * resid * inv_sigma2;
1081            h[[i, 0, 1]] = wi * 2.0 * resid * inv_sigma2;
1082            h[[i, 1, 0]] = h[[i, 0, 1]];
1083        }
1084        Ok(Self { h })
1085    }
1086
1087    /// Construct from pilot predictors (μ and log σ at current β) and data,
1088    /// with PSD eigenvalue clamping applied per subject.
1089    ///
1090    /// `y` is the response, `w` the per-row sample weights, `eta_mu` and
1091    /// `eta_log_sigma` the current linear predictors. Negative eigenvalues
1092    /// are projected to zero (PSD clamp) before storage so the resulting
1093    /// matrix is a valid metric for the W-Gram identifiability compile.
1094    pub fn from_pilot(
1095        y: &ndarray::Array1<f64>,
1096        w: &ndarray::Array1<f64>,
1097        eta_mu: &ndarray::Array1<f64>,
1098        eta_log_sigma: &ndarray::Array1<f64>,
1099    ) -> Result<Self, String> {
1100        let n = y.len();
1101        if w.len() != n || eta_mu.len() != n || eta_log_sigma.len() != n {
1102            return Err(format!(
1103                "GaussianLocationScaleChannelHessian::from_pilot: \
1104                 length mismatch y={n} w={} eta_mu={} eta_log_sigma={}",
1105                w.len(),
1106                eta_mu.len(),
1107                eta_log_sigma.len(),
1108            ));
1109        }
1110        let mut h = ndarray::Array3::<f64>::zeros((n, 2, 2));
1111        for i in 0..n {
1112            let wi = w[i];
1113            let mu_i = eta_mu[i];
1114            let s_i = eta_log_sigma[i];
1115            let inv_sigma2 = (-2.0 * s_i).exp(); // exp(-2s) = 1/sigma^2
1116            let resid = y[i] - mu_i;
1117            // Hessian of w_i * ρ_i
1118            let h00 = wi * inv_sigma2;
1119            let h11 = wi * 2.0 * resid * resid * inv_sigma2;
1120            let h01 = wi * 2.0 * resid * inv_sigma2;
1121            // PSD clamp via eigendecomposition of 2×2 matrix.
1122            // psd_clamp_2x2 returns (λ1, λ2, u1[0], u1[1], u2[0], u2[1])
1123            // where u1 and u2 are unit eigenvectors for λ1 and λ2.
1124            // Reconstruction: H_psd = λ1·u1·u1ᵀ + λ2·u2·u2ᵀ
1125            let (e0, e1, u1_0, u1_1, u2_0, u2_1) = psd_clamp_2x2(h00, h01, h11);
1126            h[[i, 0, 0]] = e0 * u1_0 * u1_0 + e1 * u2_0 * u2_0;
1127            h[[i, 0, 1]] = e0 * u1_0 * u1_1 + e1 * u2_0 * u2_1;
1128            h[[i, 1, 0]] = h[[i, 0, 1]];
1129            h[[i, 1, 1]] = e0 * u1_1 * u1_1 + e1 * u2_1 * u2_1;
1130        }
1131        Ok(Self { h })
1132    }
1133}
1134
1135impl FamilyChannelHessian for GaussianLocationScaleChannelHessian {
1136    fn n_outputs(&self) -> usize {
1137        2
1138    }
1139
1140    fn n_subjects(&self) -> usize {
1141        self.h.shape()[0]
1142    }
1143
1144    fn fill_subject(&self, i: usize, out: &mut [f64]) {
1145        assert_eq!(out.len(), 4);
1146        out[0] = self.h[[i, 0, 0]];
1147        out[1] = self.h[[i, 0, 1]];
1148        out[2] = self.h[[i, 1, 0]];
1149        out[3] = self.h[[i, 1, 1]];
1150    }
1151
1152    fn evaluate_full(&self) -> ndarray::Array3<f64> {
1153        self.h.clone()
1154    }
1155}
1156
1157impl CustomFamily for GaussianLocationScaleFamily {
1158    /// The Gaussian location-scale joint curvature is the OBSERVED joint
1159    /// Hessian (Wood–Pya–Säfken 2016 LAML object; #1561): (μ,μ) weight `w = a/σ²`,
1160    /// cross `2κm`, (log σ,log σ) `κ'(a−n)+2κ²n` — see
1161    /// `gaussian_locscale_observed_joint_row_coeffs`. Residual-dependent cross /
1162    /// scale weights supply the Schur deficit and fitted-residual shrinkage the
1163    /// block-Fisher object (#684/#566) dropped, which had biased λ̂_σ upward on
1164    /// flat scale surfaces. Both observed weights depend on β through μ (via the
1165    /// residual in m,n) and through the scale predictor (σ,κ), so the curvature
1166    /// moves when either block moves — hence this override is `true`. The
1167    /// β-dependence is essential for correct M_j[u] drift corrections when ψ
1168    /// hyperparameters move the design matrices.
1169    fn exact_newton_joint_hessian_beta_dependent(&self) -> bool {
1170        true
1171    }
1172
1173    /// Gaussian location-scale carries a NON-profiled second (log-σ) linear
1174    /// predictor, so — unlike an ordinary Gaussian GAM whose scalar dispersion is
1175    /// profiled out analytically — its smoothing-parameter selection exhibits the
1176    /// same capped-screening over-smoothing bias as a GLM block: the capped
1177    /// inner-iteration screening proxy ranks an over-smoothed scale seed cheapest
1178    /// (its coefficients collapse into the penalty null space and the proxy looks
1179    /// converged), so the log-σ smooth is flattened toward a constant σ, the
1180    /// 1/σ² IRLS weights go wrong, and the weight-coupled mean degrades too.
1181    ///
1182    /// The default trait config classifies this as the generic
1183    /// `GeneralizedLinear` profile (seed_budget=1, capped screening, a seed grid
1184    /// reaching only ρ≈−2, and the *parsimonious* — smoothing-biased — keep-best),
1185    /// every part of which pushes the scale toward over-smoothing. The spatial
1186    /// (Matérn/GP) location-scale path already classifies the family as
1187    /// `GaussianLocationScale`; this override extends that same correct
1188    /// classification to the NON-spatial (thin-plate / P-spline) rho-only path,
1189    /// which is the one a `s(x, bs='tp')` location-scale fit actually takes. The
1190    /// `GaussianLocationScale` profile reuses Gaussian's flexible seed grid (which
1191    /// reaches the low-λ scale basin) and Gaussian's lowest-cost keep-best (no
1192    /// smoothing-biased tie-break), while still taking the interior-extreme seed
1193    /// promotion so the flexible basin is actually full-solved. The budget mirrors
1194    /// the spatial `exact_joint_seed_config(Gaussian)` (max_seeds=4, seed_budget=2).
1195    fn outer_seed_config(&self, n_params: usize) -> crate::seeding::SeedConfig {
1196        if n_params == 0 {
1197            return crate::seeding::SeedConfig::default();
1198        }
1199        let mut config = crate::seeding::SeedConfig::default();
1200        config.risk_profile = crate::seeding::SeedRiskProfile::GaussianLocationScale;
1201        config.max_seeds = 4;
1202        config.seed_budget = 2;
1203        config
1204    }
1205
1206    /// Two independent linear predictors: block 0 → μ channel, block 1 → log σ
1207    /// channel. Declaring the channel topology lets `fit_custom_family` route
1208    /// the identifiability audit channel-aware even when a caller builds the
1209    /// blocks by hand (without `build_location_scale_block`'s callbacks), so a
1210    /// shared μ/log-σ covariate basis is recognised as block-diagonal rather
1211    /// than mistaken for cross-block intercept aliases (#558).
1212    fn output_channel_assignment(&self, specs: &[ParameterBlockSpec]) -> Option<Vec<usize>> {
1213        // Two-channel families: `[mu, log_sigma]`. The optional trailing
1214        // zero-channel wiggle block (when present) also drives channel 0.
1215        Some(
1216            (0..specs.len())
1217                .map(|i| usize::from(i == Self::BLOCK_LOG_SIGMA))
1218                .collect(),
1219        )
1220    }
1221
1222    fn coefficient_hessian_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
1223        // Operator-aware: when the unified evaluator picks the matrix-free
1224        // joint Hessian path (see `use_joint_matrix_free_path`), the workspace
1225        // applies the joint Hessian via row-streaming Khatri-Rao matvecs at
1226        // O(n · (p_t + p_ℓ)) per Hv, never building the dense (p_t + p_ℓ)²
1227        // matrix. Report the operator work model so diagnostics and
1228        // first-order-only policies reflect the representation that actually
1229        // runs.
1230        crate::location_scale_engine::location_scale_coefficient_hessian_cost(
1231            self.y.len() as u64,
1232            specs,
1233        )
1234    }
1235
1236    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
1237        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
1238        let n = self.y.len();
1239        let etamu = &block_states[Self::BLOCK_MU].eta;
1240        let eta_log_sigma = &block_states[Self::BLOCK_LOG_SIGMA].eta;
1241        if etamu.len() != n || eta_log_sigma.len() != n || self.weights.len() != n {
1242            return Err(GamlssError::DimensionMismatch {
1243                reason: "GaussianLocationScaleFamily input size mismatch".to_string(),
1244            }
1245            .into());
1246        }
1247
1248        // Diagonal IRLS weights for the inner solver.
1249        //
1250        // For the location block (identity link): wmu = pw / sigma^2. Since the
1251        // location link is identity, observed = Fisher --- no correction needed.
1252        //
1253        // For the log-sigma block (log link): w_ls = 2 * pw * (dsigma/deta)^2 / sigma^2.
1254        // This is the Fisher weight. For the outer REML, the joint
1255        // `exact_newton_joint_hessian` provides the full observed Hessian directly,
1256        // so these Diagonal weights are only used for the inner IRLS iteration
1257        // (where Fisher scoring is fine). See response.md Section 3.
1258        //
1259        let mut zmu = Array1::<f64>::zeros(n);
1260        let mut wmu = Array1::<f64>::zeros(n);
1261        let mut z_ls = Array1::<f64>::zeros(n);
1262        let mut w_ls = Array1::<f64>::zeros(n);
1263        let ln2pi = (2.0 * std::f64::consts::PI).ln();
1264        let mut ll = 0.0;
1265
1266        const CHUNK: usize = 1024;
1267        if let (
1268            Some(y_s),
1269            Some(w_s),
1270            Some(mu_s),
1271            Some(ls_s),
1272            Some(zmu_s),
1273            Some(wmu_s),
1274            Some(zls_s),
1275            Some(wls_s),
1276        ) = (
1277            self.y.as_slice_memory_order(),
1278            self.weights.as_slice_memory_order(),
1279            etamu.as_slice_memory_order(),
1280            eta_log_sigma.as_slice_memory_order(),
1281            zmu.as_slice_memory_order_mut(),
1282            wmu.as_slice_memory_order_mut(),
1283            z_ls.as_slice_memory_order_mut(),
1284            w_ls.as_slice_memory_order_mut(),
1285        ) {
1286            // Per-row Gaussian LS kernel writes 4 working arrays directly into
1287            // the output slices; ll is reduced via Rayon's sum. Independent
1288            // across rows.
1289            ll += zmu_s
1290                .par_chunks_mut(CHUNK)
1291                .zip(wmu_s.par_chunks_mut(CHUNK))
1292                .zip(zls_s.par_chunks_mut(CHUNK))
1293                .zip(wls_s.par_chunks_mut(CHUNK))
1294                .enumerate()
1295                .map(|(chunk_idx, (((zmu_c, wmu_c), zls_c), wls_c))| {
1296                    let start = chunk_idx * CHUNK;
1297                    let mut local_ll = 0.0;
1298                    for local in 0..zmu_c.len() {
1299                        let i = start + local;
1300                        let row =
1301                            gaussian_diagonal_row_kernel(y_s[i], mu_s[i], ls_s[i], w_s[i], ln2pi);
1302                        zmu_c[local] = mu_s[i] + row.location_working_shift;
1303                        wmu_c[local] = row.location_working_weight;
1304                        zls_c[local] = row.log_sigma_working_response;
1305                        wls_c[local] = row.log_sigma_working_weight;
1306                        local_ll += row.log_likelihood;
1307                    }
1308                    local_ll
1309                })
1310                .sum::<f64>();
1311        } else {
1312            // Fallback path: inputs are not contiguous. Outputs (just-allocated
1313            // Array1::zeros) always are. Reborrow input views into the closure.
1314            let y_view = self.y.view();
1315            let w_view = self.weights.view();
1316            let mu_view = etamu.view();
1317            let ls_view = eta_log_sigma.view();
1318            let zmu_s = zmu
1319                .as_slice_memory_order_mut()
1320                .expect("zeros is contiguous");
1321            let wmu_s = wmu
1322                .as_slice_memory_order_mut()
1323                .expect("zeros is contiguous");
1324            let zls_s = z_ls
1325                .as_slice_memory_order_mut()
1326                .expect("zeros is contiguous");
1327            let wls_s = w_ls
1328                .as_slice_memory_order_mut()
1329                .expect("zeros is contiguous");
1330            ll += zmu_s
1331                .par_chunks_mut(CHUNK)
1332                .zip(wmu_s.par_chunks_mut(CHUNK))
1333                .zip(zls_s.par_chunks_mut(CHUNK))
1334                .zip(wls_s.par_chunks_mut(CHUNK))
1335                .enumerate()
1336                .map(|(chunk_idx, (((zmu_c, wmu_c), zls_c), wls_c))| {
1337                    let start = chunk_idx * CHUNK;
1338                    let mut local_ll = 0.0;
1339                    for local in 0..zmu_c.len() {
1340                        let i = start + local;
1341                        let row = gaussian_diagonal_row_kernel(
1342                            y_view[i], mu_view[i], ls_view[i], w_view[i], ln2pi,
1343                        );
1344                        zmu_c[local] = mu_view[i] + row.location_working_shift;
1345                        wmu_c[local] = row.location_working_weight;
1346                        zls_c[local] = row.log_sigma_working_response;
1347                        wls_c[local] = row.log_sigma_working_weight;
1348                        local_ll += row.log_likelihood;
1349                    }
1350                    local_ll
1351                })
1352                .sum::<f64>();
1353        }
1354
1355        Ok(FamilyEvaluation {
1356            log_likelihood: ll,
1357            blockworking_sets: vec![
1358                BlockWorkingSet::diagonal_checked(zmu, wmu)?,
1359                BlockWorkingSet::diagonal_checked(z_ls, w_ls)?,
1360            ],
1361        })
1362    }
1363
1364    fn log_likelihood_only(&self, block_states: &[ParameterBlockState]) -> Result<f64, String> {
1365        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
1366        let n = self.y.len();
1367        let etamu = &block_states[Self::BLOCK_MU].eta;
1368        let eta_log_sigma = &block_states[Self::BLOCK_LOG_SIGMA].eta;
1369        if etamu.len() != n || eta_log_sigma.len() != n || self.weights.len() != n {
1370            return Err(GamlssError::DimensionMismatch {
1371                reason: "GaussianLocationScaleFamily input size mismatch".to_string(),
1372            }
1373            .into());
1374        }
1375        // logb noise link: σ(η_ls) = LOGB_SIGMA_FLOOR + exp(η_ls). σ ≥ b > 0
1376        // bounds the loglik below (−Σlog σ ≥ −n log b) and bounds 1/σ² by 1/b²,
1377        // so the previous `inv_s2.min(1e24)` cap is structurally unnecessary.
1378        let ln2pi = (2.0 * std::f64::consts::PI).ln();
1379        let mut ll = 0.0;
1380        if let (Some(y_s), Some(w_s), Some(mu_s), Some(ls_s)) = (
1381            self.y.as_slice_memory_order(),
1382            self.weights.as_slice_memory_order(),
1383            etamu.as_slice_memory_order(),
1384            eta_log_sigma.as_slice_memory_order(),
1385        ) {
1386            use rayon::iter::{IntoParallelIterator, ParallelIterator};
1387            ll += (0..n)
1388                .into_par_iter()
1389                .map(|i| {
1390                    let wi = w_s[i];
1391                    if wi == 0.0 {
1392                        return 0.0;
1393                    }
1394                    let sigma_i = logb_sigma_from_eta_scalar(ls_s[i]);
1395                    let inv_s2 = (sigma_i * sigma_i).recip();
1396                    let r = y_s[i] - mu_s[i];
1397                    wi * (-0.5 * (r * r * inv_s2 + ln2pi + 2.0 * sigma_i.ln()))
1398                })
1399                .sum::<f64>();
1400        } else {
1401            use rayon::iter::{IntoParallelIterator, ParallelIterator};
1402            ll += (0..n)
1403                .into_par_iter()
1404                .map(|i| {
1405                    let wi = self.weights[i];
1406                    if wi == 0.0 {
1407                        return 0.0;
1408                    }
1409                    let sigma_i = logb_sigma_from_eta_scalar(eta_log_sigma[i]);
1410                    let inv_s2 = (sigma_i * sigma_i).recip();
1411                    let r = self.y[i] - etamu[i];
1412                    wi * (-0.5 * (r * r * inv_s2 + ln2pi + 2.0 * sigma_i.ln()))
1413                })
1414                .sum::<f64>();
1415        }
1416        Ok(ll)
1417    }
1418
1419    /// Outer-only log-likelihood with optional row subsample.
1420    ///
1421    /// When `options.outer_score_subsample` is `Some`, only the sampled rows
1422    /// contribute; each row's per-row log-likelihood term is multiplied by
1423    /// `WeightedOuterRow.weight`, the Horvitz–Thompson inverse-inclusion
1424    /// factor 1/π_i (uniform or stratified sampling both supported), so the
1425    /// partial sum is an unbiased estimator of the full-data log-likelihood.
1426    /// When `None`, this returns the full-data `log_likelihood_only`. Inner
1427    /// PIRLS line searches never install the subsample option, so they
1428    /// continue to score the exact full-data log-likelihood.
1429    fn log_likelihood_only_with_options(
1430        &self,
1431        block_states: &[ParameterBlockState],
1432        options: &BlockwiseFitOptions,
1433    ) -> Result<f64, String> {
1434        let Some(subsample) = options.outer_score_subsample.as_ref() else {
1435            return self.log_likelihood_only(block_states);
1436        };
1437        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
1438        let n = self.y.len();
1439        let etamu = &block_states[Self::BLOCK_MU].eta;
1440        let eta_log_sigma = &block_states[Self::BLOCK_LOG_SIGMA].eta;
1441        if etamu.len() != n || eta_log_sigma.len() != n || self.weights.len() != n {
1442            return Err(GamlssError::DimensionMismatch {
1443                reason: "GaussianLocationScaleFamily input size mismatch".to_string(),
1444            }
1445            .into());
1446        }
1447        let ln2pi = (2.0 * std::f64::consts::PI).ln();
1448        use rayon::iter::ParallelIterator;
1449        let ll: f64 = subsample
1450            .rows
1451            .par_iter()
1452            .map(|row| {
1453                let i = row.index;
1454                let wi = self.weights[i];
1455                if wi == 0.0 {
1456                    return 0.0;
1457                }
1458                let sigma_i = logb_sigma_from_eta_scalar(eta_log_sigma[i]);
1459                let inv_s2 = (sigma_i * sigma_i).recip();
1460                let r = self.y[i] - etamu[i];
1461                row.weight * wi * (-0.5 * (r * r * inv_s2 + ln2pi + 2.0 * sigma_i.ln()))
1462            })
1463            .sum();
1464        Ok(ll)
1465    }
1466
1467    fn exact_newton_joint_hessian(
1468        &self,
1469        block_states: &[ParameterBlockState],
1470    ) -> Result<Option<Array2<f64>>, String> {
1471        self.exact_newton_joint_hessian_for_specs(block_states, None)
1472    }
1473
1474    fn exact_newton_joint_gradient_evaluation(
1475        &self,
1476        block_states: &[ParameterBlockState],
1477        specs: &[ParameterBlockSpec],
1478    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
1479        self.exact_newton_joint_gradient_for_specs(block_states, Some(specs))
1480    }
1481
1482    fn has_explicit_joint_hessian(&self) -> bool {
1483        true
1484    }
1485
1486    /// The Gaussian location-scale likelihood has no separation /
1487    /// under-identification regime that the full-span Jeffreys curvature `H_Φ`
1488    /// is meant to regularize: with the soft floor `σ ≥ b > 0` the per-row
1489    /// Fisher information `diag(a/σ², 2κ²a)` is bounded and `O(n)` on every
1490    /// identified direction at every working point, so the well-conditioned-`H`
1491    /// Jeffreys gate smooth-steps `H_Φ` to ~0 — yet the matching score `∇Φ`
1492    /// kept leaking a *phantom* penalized-stationarity residual into the inner
1493    /// joint-Newton (a nonzero `|∇L − Sβ|` paired with a numerically null `H_Φ`
1494    /// and a full-rank `H_pen`), so the KKT certificate refused every iterate
1495    /// and the outer REML rejected all seeds — aborting heteroscedastic
1496    /// location-scale fits (#684–#688). This is the same opt-out
1497    /// `TransformationNormalFamily` takes for the same structural reason
1498    /// (continuous response, `O(n)` Fisher information everywhere); it removes
1499    /// the phantom residual and drops the per-cycle `O(n·p²)` Jeffreys
1500    /// directional-derivative overhead.
1501    fn joint_jeffreys_term_required(&self) -> bool {
1502        false
1503    }
1504
1505    fn exact_newton_joint_hessian_directional_derivative(
1506        &self,
1507        block_states: &[ParameterBlockState],
1508        d_beta_flat: &Array1<f64>,
1509    ) -> Result<Option<Array2<f64>>, String> {
1510        self.exact_newton_joint_hessian_directional_derivative_for_specs(
1511            block_states,
1512            None,
1513            d_beta_flat,
1514        )
1515    }
1516
1517    fn exact_newton_joint_hessiansecond_directional_derivative(
1518        &self,
1519        block_states: &[ParameterBlockState],
1520        d_beta_u_flat: &Array1<f64>,
1521        d_betav_flat: &Array1<f64>,
1522    ) -> Result<Option<Array2<f64>>, String> {
1523        self.exact_newton_joint_hessian_second_directional_derivative_for_specs(
1524            block_states,
1525            None,
1526            d_beta_u_flat,
1527            d_betav_flat,
1528        )
1529    }
1530
1531    fn diagonalworking_weights_directional_derivative(
1532        &self,
1533        block_states: &[ParameterBlockState],
1534        block_idx: usize,
1535        d_eta: &Array1<f64>,
1536    ) -> Result<Option<Array1<f64>>, String> {
1537        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
1538        let n = self.y.len();
1539        let eta_t = &block_states[Self::BLOCK_MU].eta;
1540        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
1541        if eta_t.len() != n || eta_ls.len() != n || self.weights.len() != n || d_eta.len() != n {
1542            return Err(GamlssError::DimensionMismatch {
1543                reason: "GaussianLocationScaleFamily input size mismatch".to_string(),
1544            }
1545            .into());
1546        }
1547
1548        let sigma = eta_ls.mapv(logb_sigma_from_eta_scalar);
1549        let mut dw = Array1::<f64>::zeros(n);
1550        match block_idx {
1551            Self::BLOCK_MU => {
1552                // Gaussian location block:
1553                //
1554                //   wmu = weight / sigma^2.
1555                //
1556                // This depends only on the scale predictor, so along a
1557                // location-only direction d etamu the directional derivative is
1558                // identically zero.
1559                Ok(Some(dw))
1560            }
1561            Self::BLOCK_LOG_SIGMA => {
1562                // Gaussian log-sigma block:
1563                //
1564                // The PIRLS information weight is
1565                //
1566                //   w_ls = max(2 * weight * clamp(g, -1, 1)^2, MIN_WEIGHT),
1567                //   g    = sigma'(eta_ls) / sigma(eta_ls),
1568                // with the semantic rule that zero observation weights stay zero.
1569                //
1570                // Along a direction d eta_ls,
1571                //
1572                //   dw_ls is the directional derivative of that piecewise
1573                //   definition. On the active clamp branch or active MIN_WEIGHT
1574                //   floor branch, the returned derivative is zero to match the
1575                //   selected local piece of the evaluated weight.
1576                //
1577                // This is the exact directional derivative needed by the REML
1578                // trace term
1579                //
1580                //   0.5 tr(J^{-1} D_beta J[u])
1581                //   = 0.5 sum_i (x_i^T J^{-1} x_i) dw_i
1582                //
1583                // for diagonal working-set blocks.
1584                use rayon::iter::{IntoParallelIterator, ParallelIterator};
1585                let dw_vec: Vec<f64> = (0..n)
1586                    .into_par_iter()
1587                    .map(|i| {
1588                        let d1 = crate::sigma_link::logb_sigma_jet1_scalar(eta_ls[i]).d1;
1589                        gaussian_log_sigma_irlsinfo_directional_derivative(
1590                            self.weights[i],
1591                            sigma[i],
1592                            d1,
1593                            d_eta[i],
1594                        )
1595                    })
1596                    .collect();
1597                for (i, v) in dw_vec.into_iter().enumerate() {
1598                    dw[i] = v;
1599                }
1600                Ok(Some(dw))
1601            }
1602            _ => Ok(None),
1603        }
1604    }
1605
1606    fn exact_newton_joint_hessian_with_specs(
1607        &self,
1608        block_states: &[ParameterBlockState],
1609        specs: &[ParameterBlockSpec],
1610    ) -> Result<Option<Array2<f64>>, String> {
1611        self.exact_newton_joint_hessian_for_specs(block_states, Some(specs))
1612    }
1613
1614    fn exact_newton_joint_hessian_directional_derivative_with_specs(
1615        &self,
1616        block_states: &[ParameterBlockState],
1617        specs: &[ParameterBlockSpec],
1618        d_beta_flat: &Array1<f64>,
1619    ) -> Result<Option<Array2<f64>>, String> {
1620        self.exact_newton_joint_hessian_directional_derivative_for_specs(
1621            block_states,
1622            Some(specs),
1623            d_beta_flat,
1624        )
1625    }
1626
1627    fn exact_newton_joint_hessian_second_directional_derivative_with_specs(
1628        &self,
1629        block_states: &[ParameterBlockState],
1630        specs: &[ParameterBlockSpec],
1631        d_beta_u_flat: &Array1<f64>,
1632        d_betav_flat: &Array1<f64>,
1633    ) -> Result<Option<Array2<f64>>, String> {
1634        self.exact_newton_joint_hessian_second_directional_derivative_for_specs(
1635            block_states,
1636            Some(specs),
1637            d_beta_u_flat,
1638            d_betav_flat,
1639        )
1640    }
1641
1642    fn exact_newton_joint_psi_terms(
1643        &self,
1644        block_states: &[ParameterBlockState],
1645        specs: &[ParameterBlockSpec],
1646        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
1647        psi_index: usize,
1648    ) -> Result<Option<gam_problem::ExactNewtonJointPsiTerms>, String> {
1649        self.exact_newton_joint_psi_terms_for_specs(
1650            block_states,
1651            specs,
1652            derivative_blocks,
1653            psi_index,
1654        )
1655    }
1656
1657    fn exact_newton_joint_psisecond_order_terms(
1658        &self,
1659        block_states: &[ParameterBlockState],
1660        specs: &[ParameterBlockSpec],
1661        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
1662        psi_i: usize,
1663        psi_j: usize,
1664    ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderTerms>, String> {
1665        self.exact_newton_joint_psisecond_order_terms_for_specs(
1666            block_states,
1667            specs,
1668            derivative_blocks,
1669            psi_i,
1670            psi_j,
1671        )
1672    }
1673
1674    fn exact_newton_joint_psihessian_directional_derivative(
1675        &self,
1676        block_states: &[ParameterBlockState],
1677        specs: &[ParameterBlockSpec],
1678        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
1679        psi_index: usize,
1680        d_beta_flat: &Array1<f64>,
1681    ) -> Result<Option<Array2<f64>>, String> {
1682        self.exact_newton_joint_psihessian_directional_derivative_for_specs(
1683            block_states,
1684            specs,
1685            derivative_blocks,
1686            psi_index,
1687            d_beta_flat,
1688        )
1689    }
1690
1691    fn exact_newton_joint_psi_workspace(
1692        &self,
1693        block_states: &[ParameterBlockState],
1694        specs: &[ParameterBlockSpec],
1695        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
1696    ) -> Result<Option<Arc<dyn ExactNewtonJointPsiWorkspace>>, String> {
1697        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
1698        if specs.len() != 2 || derivative_blocks.len() != 2 {
1699            return Err(GamlssError::DimensionMismatch { reason: format!(
1700                "GaussianLocationScaleFamily joint psi workspace expects 2 specs and 2 derivative block lists, got {} / {}",
1701                specs.len(),
1702                derivative_blocks.len()
1703            ) }.into());
1704        }
1705        Ok(Some(Arc::new(
1706            GaussianLocationScaleExactNewtonJointPsiWorkspace::new(
1707                self.clone(),
1708                block_states.to_vec(),
1709                specs,
1710                derivative_blocks.to_vec(),
1711            )?,
1712        )))
1713    }
1714
1715    /// Outer-aware joint ψ workspace with optional row subsample.
1716    ///
1717    /// When `options.outer_score_subsample` is `None`, this is byte-identical
1718    /// to `exact_newton_joint_psi_workspace`. When `Some`, the subsample is
1719    /// stored in the workspace and forwarded into every per-row weight array
1720    /// produced by `gaussian_joint_psi_firstweights`,
1721    /// `gaussian_joint_psisecondweights`, and
1722    /// `gaussian_joint_psi_mixed_driftweights`: each sampled row's
1723    /// contribution is multiplied by `WeightedOuterRow.weight = 1/π_i` and
1724    /// non-sampled rows are zeroed. Every downstream assembly
1725    /// (`gaussian_joint_psi*_fromweights`, `weighted_crossprod_psi_maps`,
1726    /// `xt_diag_*_dense`,
1727    /// `build_two_block_custom_family_joint_psi_operator_from_actions`) is
1728    /// row-linear in these arrays via `Xᵀ diag(W) Y`, so the resulting
1729    /// second-order ψ Hessian and ψ-Hessian directional derivative are
1730    /// unbiased Horvitz–Thompson estimators of the full-data quantities.
1731    /// Inner-PIRLS and final-covariance paths never install the option.
1732    fn exact_newton_joint_psi_workspace_with_options(
1733        &self,
1734        block_states: &[ParameterBlockState],
1735        specs: &[ParameterBlockSpec],
1736        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
1737        options: &BlockwiseFitOptions,
1738    ) -> Result<Option<Arc<dyn ExactNewtonJointPsiWorkspace>>, String> {
1739        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
1740        if specs.len() != 2 || derivative_blocks.len() != 2 {
1741            return Err(GamlssError::DimensionMismatch { reason: format!(
1742                "GaussianLocationScaleFamily joint psi workspace expects 2 specs and 2 derivative block lists, got {} / {}",
1743                specs.len(),
1744                derivative_blocks.len()
1745            ) }.into());
1746        }
1747        Ok(Some(Arc::new(
1748            GaussianLocationScaleExactNewtonJointPsiWorkspace::new_with_subsample(
1749                self.clone(),
1750                block_states.to_vec(),
1751                specs,
1752                derivative_blocks.to_vec(),
1753                options.outer_score_subsample.clone(),
1754            )?,
1755        )))
1756    }
1757
1758    fn exact_newton_joint_hessian_workspace(
1759        &self,
1760        block_states: &[ParameterBlockState],
1761        specs: &[ParameterBlockSpec],
1762    ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
1763        let Some((xmu, x_ls)) = self.exact_joint_dense_block_designs(Some(specs))? else {
1764            return Ok(None);
1765        };
1766        let workspace = GaussianLocationScaleHessianWorkspace::new(
1767            self.clone(),
1768            block_states.to_vec(),
1769            xmu.into_owned(),
1770            x_ls.into_owned(),
1771        )?;
1772        Ok(Some(Arc::new(workspace)))
1773    }
1774
1775    /// Outer-aware joint-Hessian workspace with optional row subsample.
1776    ///
1777    /// When `options.outer_score_subsample` is `None`, this is byte-identical
1778    /// to `exact_newton_joint_hessian_workspace`. When `Some`, the precomputed
1779    /// per-row coefficient arrays (`coeff_mm`, `coeff_ml`, `coeff_ll`) — which
1780    /// every downstream assembly (`hessian_dense`, `hessian_matvec`,
1781    /// `hessian_diagonal`) consumes row-linearly via `Xᵀ diag(W) X` — are
1782    /// replaced by a Horvitz–Thompson mask: each sampled row's coefficient is
1783    /// multiplied by `WeightedOuterRow.weight` (the inverse-inclusion factor
1784    /// 1/π_i; uniform or stratified sampling both supported), and non-sampled
1785    /// rows are zeroed. The resulting joint Hessian is an unbiased estimator
1786    /// of the full-data joint Hessian. Inner PIRLS never installs the option,
1787    /// so the inner solve continues to consume the exact full-data Hessian.
1788    fn exact_newton_joint_hessian_workspace_with_options(
1789        &self,
1790        block_states: &[ParameterBlockState],
1791        specs: &[ParameterBlockSpec],
1792        options: &BlockwiseFitOptions,
1793    ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
1794        let Some((xmu, x_ls)) = self.exact_joint_dense_block_designs(Some(specs))? else {
1795            return Ok(None);
1796        };
1797        let mut workspace = GaussianLocationScaleHessianWorkspace::new(
1798            self.clone(),
1799            block_states.to_vec(),
1800            xmu.into_owned(),
1801            x_ls.into_owned(),
1802        )?;
1803        if let Some(subsample) = options.outer_score_subsample.as_ref() {
1804            workspace.apply_outer_subsample(subsample.rows.as_ref());
1805        }
1806        Ok(Some(Arc::new(workspace)))
1807    }
1808
1809    fn inner_coefficient_hessian_hvp_available(&self, specs: &[ParameterBlockSpec]) -> bool {
1810        // The Gaussian location-scale workspace is returned by
1811        // `exact_newton_joint_hessian_workspace` whenever
1812        // `exact_joint_dense_block_designs` succeeds, which itself depends on
1813        // both block designs being present. This is only a β-space operator
1814        // capability; outer θθ Hessian availability is declared separately.
1815        self.exact_joint_supported()
1816            && matches!(
1817                self.exact_joint_dense_block_designs(Some(specs)),
1818                Ok(Some(_))
1819            )
1820    }
1821
1822    /// Outer-derivative policy: declare HT-subsample capability.
1823    ///
1824    /// GaussianLocationScaleFamily overrides
1825    /// `log_likelihood_only_with_options`,
1826    /// `exact_newton_joint_hessian_workspace_with_options`, and
1827    /// `exact_newton_joint_psi_workspace_with_options` to consume
1828    /// `options.outer_score_subsample` with per-row Horvitz–Thompson weights
1829    /// (each sampled row's contribution is multiplied by
1830    /// `WeightedOuterRow.weight = 1/π_i`; non-sampled rows are zeroed),
1831    /// yielding unbiased estimators of the full-data log-likelihood, joint
1832    /// Hessian, and second-order ψ Hessian / ψ-Hessian directional
1833    /// derivative. The ψ-workspace masking happens inside
1834    /// `apply_ht_mask_first`, `apply_ht_mask_second`, and
1835    /// `apply_ht_mask_mixed` on the `GaussianJointPsi{First,Second,
1836    /// MixedDrift}Weights` per-row arrays, immediately after the row-scalar
1837    /// reductions and before the row-linear `weighted_crossprod_psi_maps` /
1838    /// `xt_diag_*_dense` assemblies, so the masked outputs remain unbiased.
1839    /// First-order ψ terms remain full-data exact (= trivially unbiased), so
1840    /// the total outer score is still unbiased. Inner-PIRLS and final-
1841    /// covariance paths never install the option, so they continue to
1842    /// consume the exact full-data quantities.
1843    fn outer_derivative_subsample_capable(&self) -> bool {
1844        true
1845    }
1846}
1847
1848impl CustomFamilyGenerative for GaussianLocationScaleFamily {
1849    fn generativespec(
1850        &self,
1851        block_states: &[ParameterBlockState],
1852    ) -> Result<GenerativeSpec, String> {
1853        validate_block_count::<GamlssError>("GaussianLocationScaleFamily", 2, block_states.len())?;
1854        let mu = block_states[Self::BLOCK_MU].eta.clone();
1855        let eta_log_sigma = &block_states[Self::BLOCK_LOG_SIGMA].eta;
1856        let sigma = gamlss_rowwise_map(eta_log_sigma.len(), |i| {
1857            logb_sigma_from_eta_scalar(eta_log_sigma[i])
1858        });
1859        Ok(GenerativeSpec {
1860            mean: mu,
1861            noise: NoiseModel::Gaussian { sigma },
1862        })
1863    }
1864}