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