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