Skip to main content

gam_models/gamlss/binomial/
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
7impl BinomialLocationScaleFamily {
8    pub const BLOCK_T: usize = 0;
9    pub const BLOCK_LOG_SIGMA: usize = 1;
10
11    pub fn parameternames() -> &'static [&'static str] {
12        &["threshold", "log_sigma"]
13    }
14
15    pub fn parameter_links() -> &'static [ParameterLink] {
16        &[ParameterLink::InverseLink, ParameterLink::Log]
17    }
18
19    pub fn metadata() -> FamilyMetadata {
20        FamilyMetadata {
21            name: "binomial_location_scale",
22            parameternames: Self::parameternames(),
23            parameter_links: Self::parameter_links(),
24        }
25    }
26
27    pub(crate) fn exact_joint_supported(&self) -> bool {
28        self.threshold_design.is_some() && self.log_sigma_design.is_some()
29    }
30
31    pub(crate) fn dense_block_designs(
32        &self,
33    ) -> Result<(Cow<'_, Array2<f64>>, Cow<'_, Array2<f64>>), String> {
34        dense_locscale_block_designs_cached(
35            self.threshold_design.as_ref(),
36            self.log_sigma_design.as_ref(),
37            "BinomialLocationScaleFamily",
38            "BinomialLocationScale",
39            "threshold",
40            &self.policy.material_policy(),
41        )
42    }
43
44    pub(crate) fn dense_block_designs_fromspecs<'a>(
45        &self,
46        specs: &'a [ParameterBlockSpec],
47    ) -> Result<(Cow<'a, Array2<f64>>, Cow<'a, Array2<f64>>), String> {
48        dense_locscale_block_designs_fromspecs(
49            specs,
50            2,
51            "BinomialLocationScaleFamily",
52            "BinomialLocationScale",
53            Self::BLOCK_T,
54            Self::BLOCK_LOG_SIGMA,
55            "threshold",
56            &self.policy.material_policy(),
57        )
58    }
59
60    pub(crate) fn exact_joint_dense_block_designs<'a>(
61        &'a self,
62        specs: Option<&'a [ParameterBlockSpec]>,
63    ) -> Result<Option<(Cow<'a, Array2<f64>>, Cow<'a, Array2<f64>>)>, String> {
64        // The non-wiggle family is structurally capable of exact joint outer
65        // rho-derivatives whenever the realized threshold and log-sigma
66        // designs are available somewhere. Prefer cached family designs when
67        // present, but allow the outer hyper code to recover the exact same
68        // joint path from the realized `specs`.
69        //
70        // This is not a convenience fallback. The coupled profiled derivative
71        // is defined in terms of the joint mode system
72        //
73        //   H u_k = -A_k beta,
74        //
75        // so if the block specs already determine the realized joint
76        // curvature, forcing the code back onto a blockwise surrogate just
77        // because the family did not cache duplicate dense designs would be
78        // mathematically wrong.
79        if self.threshold_design.is_some() && self.log_sigma_design.is_some() {
80            return self.dense_block_designs().map(Some);
81        }
82        if let Some(specs) = specs {
83            return self.dense_block_designs_fromspecs(specs).map(Some);
84        }
85        Ok(None)
86    }
87
88    pub(crate) fn exact_joint_block_designs_owned(
89        &self,
90        specs: Option<&[ParameterBlockSpec]>,
91    ) -> Result<Option<(DesignMatrix, DesignMatrix)>, String> {
92        let designs = if let (Some(x_t), Some(x_ls)) = (
93            self.threshold_design.as_ref(),
94            self.log_sigma_design.as_ref(),
95        ) {
96            Some((x_t.clone(), x_ls.clone()))
97        } else if let Some(specs) = specs {
98            if specs.len() != 2 {
99                return Err(GamlssError::DimensionMismatch { reason: format!(
100                    "BinomialLocationScaleFamily spec-aware operator path expects 2 specs, got {}",
101                    specs.len()
102                ) }.into());
103            }
104            Some((
105                specs[Self::BLOCK_T].design.clone(),
106                specs[Self::BLOCK_LOG_SIGMA].design.clone(),
107            ))
108        } else {
109            None
110        };
111        let Some((x_t, x_ls)) = designs else {
112            return Ok(None);
113        };
114        let n = self.y.len();
115        if x_t.nrows() != n || x_ls.nrows() != n {
116            return Err(GamlssError::DimensionMismatch { reason: format!(
117                "BinomialLocationScaleFamily operator designs have row mismatch: y={}, threshold={}, log_sigma={}",
118                n,
119                x_t.nrows(),
120                x_ls.nrows()
121            ) }.into());
122        }
123        Ok(Some((x_t, x_ls)))
124    }
125
126    pub(crate) fn exact_newton_joint_gradient_from_designs(
127        &self,
128        block_states: &[ParameterBlockState],
129        x_t: &DesignMatrix,
130        x_ls: &DesignMatrix,
131    ) -> Result<ExactNewtonJointGradientEvaluation, String> {
132        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
133        let n = self.y.len();
134        let eta_t = &block_states[Self::BLOCK_T].eta;
135        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
136        if eta_t.len() != n
137            || eta_ls.len() != n
138            || self.weights.len() != n
139            || x_t.nrows() != n
140            || x_ls.nrows() != n
141        {
142            return Err(
143                "BinomialLocationScaleFamily joint gradient input size mismatch".to_string(),
144            );
145        }
146
147        let core = binomial_location_scale_core(
148            &self.y,
149            &self.weights,
150            eta_t,
151            eta_ls,
152            None,
153            &self.link_kind,
154        )?;
155        let mut grad_eta_t_v = vec![0.0_f64; n];
156        let mut grad_eta_ls_v = vec![0.0_f64; n];
157        let y_slice = self.y.as_slice().expect("y must be contiguous");
158        let w_slice = self.weights.as_slice().expect("weights must be contiguous");
159        let q0_slice = core.q0.as_slice().expect("q0 must be contiguous");
160        let eta_t_slice = eta_t.as_slice().expect("eta_t must be contiguous");
161        let eta_ls_slice = eta_ls.as_slice().expect("eta_ls must be contiguous");
162        let link_kind = &self.link_kind;
163        let gradient_pairs: Result<Vec<(f64, f64)>, String> = (0..n)
164            .into_par_iter()
165            .map(|i| {
166                let tower = binomial_location_scale_nll_tower(
167                    y_slice[i],
168                    w_slice[i],
169                    eta_t_slice[i],
170                    eta_ls_slice[i],
171                    q0_slice[i],
172                    core.mu[i],
173                    core.dmu_dq[i],
174                    core.d2mu_dq2[i],
175                    core.d3mu_dq3[i],
176                    link_kind,
177                    false,
178                )?;
179                Ok((-tower.g[0], -tower.g[1]))
180            })
181            .collect();
182        for (i, (g_t, g_ls)) in gradient_pairs?.into_iter().enumerate() {
183            grad_eta_t_v[i] = g_t;
184            grad_eta_ls_v[i] = g_ls;
185        }
186        let grad_eta_t = Array1::from_vec(grad_eta_t_v);
187        let grad_eta_ls = Array1::from_vec(grad_eta_ls_v);
188        let grad_t = x_t.transpose_vector_multiply(&grad_eta_t);
189        let grad_ls = x_ls.transpose_vector_multiply(&grad_eta_ls);
190        let total = grad_t.len() + grad_ls.len();
191        let mut gradient = Array1::<f64>::zeros(total);
192        gradient.slice_mut(s![0..grad_t.len()]).assign(&grad_t);
193        gradient.slice_mut(s![grad_t.len()..total]).assign(&grad_ls);
194        Ok(ExactNewtonJointGradientEvaluation {
195            log_likelihood: core.log_likelihood,
196            gradient,
197        })
198    }
199
200    pub(crate) fn exact_newton_joint_hessian_for_specs(
201        &self,
202        block_states: &[ParameterBlockState],
203        specs: Option<&[ParameterBlockSpec]>,
204    ) -> Result<Option<Array2<f64>>, String> {
205        let Some((x_t, x_ls)) = self.exact_joint_block_designs_owned(specs)? else {
206            return Ok(None);
207        };
208        self.exact_newton_joint_hessian_from_design_matrices(block_states, &x_t, &x_ls)
209    }
210
211    pub(crate) fn exact_newton_joint_hessian_directional_derivative_for_specs(
212        &self,
213        block_states: &[ParameterBlockState],
214        specs: Option<&[ParameterBlockSpec]>,
215        d_beta_flat: &Array1<f64>,
216    ) -> Result<Option<Array2<f64>>, String> {
217        let Some((x_t, x_ls)) = self.exact_joint_dense_block_designs(specs)? else {
218            return Ok(None);
219        };
220        self.exact_newton_joint_hessian_directional_derivative_from_designs(
221            block_states,
222            &x_t,
223            &x_ls,
224            d_beta_flat,
225        )
226    }
227
228    pub(crate) fn exact_newton_joint_hessian_second_directional_derivative_for_specs(
229        &self,
230        block_states: &[ParameterBlockState],
231        specs: Option<&[ParameterBlockSpec]>,
232        d_beta_u_flat: &Array1<f64>,
233        d_betav_flat: &Array1<f64>,
234    ) -> Result<Option<Array2<f64>>, String> {
235        let Some((x_t, x_ls)) = self.exact_joint_dense_block_designs(specs)? else {
236            return Ok(None);
237        };
238        self.exact_newton_joint_hessiansecond_directional_derivative_from_designs(
239            block_states,
240            &x_t,
241            &x_ls,
242            d_beta_u_flat,
243            d_betav_flat,
244        )
245    }
246
247    pub(crate) fn expected_joint_information_from_designs(
248        &self,
249        block_states: &[ParameterBlockState],
250        x_t: &Array2<f64>,
251        x_ls: &Array2<f64>,
252    ) -> Result<Option<Array2<f64>>, String> {
253        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
254        let n = self.y.len();
255        let eta_t = &block_states[Self::BLOCK_T].eta;
256        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
257        if eta_t.len() != n
258            || eta_ls.len() != n
259            || self.weights.len() != n
260            || x_t.nrows() != n
261            || x_ls.nrows() != n
262        {
263            return Err(GamlssError::DimensionMismatch {
264                reason: "BinomialLocationScaleFamily expected information input size mismatch"
265                    .to_string(),
266            }
267            .into());
268        }
269        let core = binomial_location_scale_core(
270            &self.y,
271            &self.weights,
272            eta_t,
273            eta_ls,
274            None,
275            &self.link_kind,
276        )?;
277        let rows: Vec<(f64, f64, f64)> = (0..n)
278            .into_par_iter()
279            .map(|i| {
280                let q = nonwiggle_q_derivs(eta_t[i], core.sigma[i]);
281                let (f, _, _) = binomial_expected_q_information_derivatives(
282                    self.weights[i],
283                    core.mu[i],
284                    core.dmu_dq[i],
285                    core.d2mu_dq2[i],
286                    core.d3mu_dq3[i],
287                );
288                (f * q.q_t * q.q_t, f * q.q_t * q.q_ls, f * q.q_ls * q.q_ls)
289            })
290            .collect();
291        let mut coeff_tt = Array1::<f64>::zeros(n);
292        let mut coeff_tl = Array1::<f64>::zeros(n);
293        let mut coeff_ll = Array1::<f64>::zeros(n);
294        for (i, (tt, tl, ll)) in rows.into_iter().enumerate() {
295            coeff_tt[i] = tt;
296            coeff_tl[i] = tl;
297            coeff_ll[i] = ll;
298        }
299        let pt = x_t.ncols();
300        let pls = x_ls.ncols();
301        let total = pt + pls;
302        let h_tt = xt_diag_x_dense(x_t, &coeff_tt)?;
303        let h_tl = xt_diag_y_dense(x_t, &coeff_tl, x_ls)?;
304        let h_ll = xt_diag_x_dense(x_ls, &coeff_ll)?;
305        let mut h = Array2::<f64>::zeros((total, total));
306        h.slice_mut(s![0..pt, 0..pt]).assign(&h_tt);
307        h.slice_mut(s![0..pt, pt..total]).assign(&h_tl);
308        h.slice_mut(s![pt..total, pt..total]).assign(&h_ll);
309        mirror_upper_to_lower(&mut h);
310        Ok(Some(h))
311    }
312
313    pub(crate) fn expected_joint_information_directional_from_designs(
314        &self,
315        block_states: &[ParameterBlockState],
316        x_t: &Array2<f64>,
317        x_ls: &Array2<f64>,
318        d_beta_flat: &Array1<f64>,
319    ) -> Result<Option<Array2<f64>>, String> {
320        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
321        let n = self.y.len();
322        let eta_t = &block_states[Self::BLOCK_T].eta;
323        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
324        if eta_t.len() != n
325            || eta_ls.len() != n
326            || self.weights.len() != n
327            || x_t.nrows() != n
328            || x_ls.nrows() != n
329        {
330            return Err(GamlssError::DimensionMismatch {
331                reason: "BinomialLocationScaleFamily expected dI input size mismatch".to_string(),
332            }
333            .into());
334        }
335        let pt = x_t.ncols();
336        let pls = x_ls.ncols();
337        let total = pt + pls;
338        if d_beta_flat.len() != total {
339            return Err(GamlssError::DimensionMismatch {
340                reason: format!(
341                    "BinomialLocationScaleFamily expected dI direction length mismatch: got {}, expected {}",
342                    d_beta_flat.len(),
343                    total
344                ),
345            }
346            .into());
347        }
348        let d_eta_t = fast_av(x_t, &d_beta_flat.slice(s![0..pt]));
349        let d_eta_ls = fast_av(x_ls, &d_beta_flat.slice(s![pt..total]));
350        let core = binomial_location_scale_core(
351            &self.y,
352            &self.weights,
353            eta_t,
354            eta_ls,
355            None,
356            &self.link_kind,
357        )?;
358        let rows: Vec<(f64, f64, f64)> = (0..n)
359            .into_par_iter()
360            .map(|i| {
361                let q = nonwiggle_q_derivs(eta_t[i], core.sigma[i]);
362                let u = nonwiggle_q_directional(q, d_eta_t[i], d_eta_ls[i]);
363                let (f, f1, _) = binomial_expected_q_information_derivatives(
364                    self.weights[i],
365                    core.mu[i],
366                    core.dmu_dq[i],
367                    core.d2mu_dq2[i],
368                    core.d3mu_dq3[i],
369                );
370                let tt = f1 * u.delta_q * q.q_t * q.q_t + 2.0 * f * q.q_t * u.delta_q_t;
371                let tl = f1 * u.delta_q * q.q_t * q.q_ls
372                    + f * (u.delta_q_t * q.q_ls + q.q_t * u.delta_q_ls);
373                let ll = f1 * u.delta_q * q.q_ls * q.q_ls + 2.0 * f * q.q_ls * u.delta_q_ls;
374                (tt, tl, ll)
375            })
376            .collect();
377        let mut coeff_tt = Array1::<f64>::zeros(n);
378        let mut coeff_tl = Array1::<f64>::zeros(n);
379        let mut coeff_ll = Array1::<f64>::zeros(n);
380        for (i, (tt, tl, ll)) in rows.into_iter().enumerate() {
381            coeff_tt[i] = tt;
382            coeff_tl[i] = tl;
383            coeff_ll[i] = ll;
384        }
385        let d_h_tt = xt_diag_x_dense(x_t, &coeff_tt)?;
386        let d_h_tl = xt_diag_y_dense(x_t, &coeff_tl, x_ls)?;
387        let d_h_ll = xt_diag_x_dense(x_ls, &coeff_ll)?;
388        let mut d_h = Array2::<f64>::zeros((total, total));
389        d_h.slice_mut(s![0..pt, 0..pt]).assign(&d_h_tt);
390        d_h.slice_mut(s![0..pt, pt..total]).assign(&d_h_tl);
391        d_h.slice_mut(s![pt..total, pt..total]).assign(&d_h_ll);
392        mirror_upper_to_lower(&mut d_h);
393        Ok(Some(d_h))
394    }
395
396    pub(crate) fn expected_joint_information_second_directional_from_designs(
397        &self,
398        block_states: &[ParameterBlockState],
399        x_t: &Array2<f64>,
400        x_ls: &Array2<f64>,
401        d_beta_u_flat: &Array1<f64>,
402        d_betav_flat: &Array1<f64>,
403    ) -> Result<Option<Array2<f64>>, String> {
404        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
405        let n = self.y.len();
406        let eta_t = &block_states[Self::BLOCK_T].eta;
407        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
408        if eta_t.len() != n
409            || eta_ls.len() != n
410            || self.weights.len() != n
411            || x_t.nrows() != n
412            || x_ls.nrows() != n
413        {
414            return Err(GamlssError::DimensionMismatch {
415                reason: "BinomialLocationScaleFamily expected d2I input size mismatch".to_string(),
416            }
417            .into());
418        }
419        let pt = x_t.ncols();
420        let pls = x_ls.ncols();
421        let total = pt + pls;
422        if d_beta_u_flat.len() != total {
423            return Err(GamlssError::DimensionMismatch { reason: format!(
424                "BinomialLocationScaleFamily expected d2I u direction length mismatch: got {}, expected {}",
425                d_beta_u_flat.len(),
426                total
427            ) }.into());
428        }
429        if d_betav_flat.len() != total {
430            return Err(GamlssError::DimensionMismatch { reason: format!(
431                "BinomialLocationScaleFamily expected d2I v direction length mismatch: got {}, expected {}",
432                d_betav_flat.len(),
433                total
434            ) }.into());
435        }
436        let d_eta_t_u = fast_av(x_t, &d_beta_u_flat.slice(s![0..pt]));
437        let d_eta_ls_u = fast_av(x_ls, &d_beta_u_flat.slice(s![pt..total]));
438        let d_eta_t_v = fast_av(x_t, &d_betav_flat.slice(s![0..pt]));
439        let d_eta_ls_v = fast_av(x_ls, &d_betav_flat.slice(s![pt..total]));
440        let core = binomial_location_scale_core(
441            &self.y,
442            &self.weights,
443            eta_t,
444            eta_ls,
445            None,
446            &self.link_kind,
447        )?;
448        let rows: Vec<(f64, f64, f64)> = (0..n)
449            .into_par_iter()
450            .map(|i| {
451                let q = nonwiggle_q_derivs(eta_t[i], core.sigma[i]);
452                let (f, f1, f2) = binomial_expected_q_information_derivatives(
453                    self.weights[i],
454                    core.mu[i],
455                    core.dmu_dq[i],
456                    core.d2mu_dq2[i],
457                    core.d3mu_dq3[i],
458                );
459                binomial_expected_location_scale_second_coefficients(
460                    q,
461                    f,
462                    f1,
463                    f2,
464                    d_eta_t_u[i],
465                    d_eta_ls_u[i],
466                    d_eta_t_v[i],
467                    d_eta_ls_v[i],
468                )
469            })
470            .collect();
471        let mut coeff_tt = Array1::<f64>::zeros(n);
472        let mut coeff_tl = Array1::<f64>::zeros(n);
473        let mut coeff_ll = Array1::<f64>::zeros(n);
474        for (i, (tt, tl, ll)) in rows.into_iter().enumerate() {
475            coeff_tt[i] = tt;
476            coeff_tl[i] = tl;
477            coeff_ll[i] = ll;
478        }
479        let d2_h_tt = xt_diag_x_dense(x_t, &coeff_tt)?;
480        let d2_h_tl = xt_diag_y_dense(x_t, &coeff_tl, x_ls)?;
481        let d2_h_ll = xt_diag_x_dense(x_ls, &coeff_ll)?;
482        let mut d2_h = Array2::<f64>::zeros((total, total));
483        d2_h.slice_mut(s![0..pt, 0..pt]).assign(&d2_h_tt);
484        d2_h.slice_mut(s![0..pt, pt..total]).assign(&d2_h_tl);
485        d2_h.slice_mut(s![pt..total, pt..total]).assign(&d2_h_ll);
486        mirror_upper_to_lower(&mut d2_h);
487        Ok(Some(d2_h))
488    }
489
490    pub(crate) fn expected_joint_contracted_trace_hessian_from_designs(
491        &self,
492        block_states: &[ParameterBlockState],
493        x_t: &Array2<f64>,
494        x_ls: &Array2<f64>,
495        trace_weight: &Array2<f64>,
496    ) -> Result<Option<Array2<f64>>, String> {
497        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
498        let n = self.y.len();
499        let eta_t = &block_states[Self::BLOCK_T].eta;
500        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
501        if eta_t.len() != n
502            || eta_ls.len() != n
503            || self.weights.len() != n
504            || x_t.nrows() != n
505            || x_ls.nrows() != n
506        {
507            return Err(GamlssError::DimensionMismatch {
508                reason: "BinomialLocationScaleFamily expected contracted trace input size mismatch"
509                    .to_string(),
510            }
511            .into());
512        }
513        let pt = x_t.ncols();
514        let pls = x_ls.ncols();
515        let total = pt + pls;
516        if trace_weight.dim() != (total, total) {
517            return Err(GamlssError::DimensionMismatch {
518                reason: format!(
519                    "BinomialLocationScaleFamily expected contracted trace weight shape {:?} == ({total}, {total})",
520                    trace_weight.dim()
521                ),
522            }
523            .into());
524        }
525        let core = binomial_location_scale_core(
526            &self.y,
527            &self.weights,
528            eta_t,
529            eta_ls,
530            None,
531            &self.link_kind,
532        )?;
533        let rows: Vec<(f64, f64, f64)> = (0..n)
534            .into_par_iter()
535            .map(|i| {
536                let mut trace_tt = 0.0;
537                for a in 0..pt {
538                    for b in 0..pt {
539                        trace_tt += x_t[[i, a]] * trace_weight[[a, b]] * x_t[[i, b]];
540                    }
541                }
542                let mut trace_tl = 0.0;
543                for a in 0..pt {
544                    for b in 0..pls {
545                        trace_tl += x_t[[i, a]]
546                            * (trace_weight[[a, pt + b]] + trace_weight[[pt + b, a]])
547                            * x_ls[[i, b]];
548                    }
549                }
550                let mut trace_ll = 0.0;
551                for a in 0..pls {
552                    for b in 0..pls {
553                        trace_ll += x_ls[[i, a]] * trace_weight[[pt + a, pt + b]] * x_ls[[i, b]];
554                    }
555                }
556                let q = nonwiggle_q_derivs(eta_t[i], core.sigma[i]);
557                let (f, f1, f2) = binomial_expected_q_information_derivatives(
558                    self.weights[i],
559                    core.mu[i],
560                    core.dmu_dq[i],
561                    core.d2mu_dq2[i],
562                    core.d3mu_dq3[i],
563                );
564                let (tt_tt, tt_tl, tt_ll) = binomial_expected_location_scale_second_coefficients(
565                    q, f, f1, f2, 1.0, 0.0, 1.0, 0.0,
566                );
567                let (tl_tt, tl_tl, tl_ll) = binomial_expected_location_scale_second_coefficients(
568                    q, f, f1, f2, 1.0, 0.0, 0.0, 1.0,
569                );
570                let (ll_tt, ll_tl, ll_ll) = binomial_expected_location_scale_second_coefficients(
571                    q, f, f1, f2, 0.0, 1.0, 0.0, 1.0,
572                );
573                (
574                    trace_tt * tt_tt + trace_tl * tt_tl + trace_ll * tt_ll,
575                    trace_tt * tl_tt + trace_tl * tl_tl + trace_ll * tl_ll,
576                    trace_tt * ll_tt + trace_tl * ll_tl + trace_ll * ll_ll,
577                )
578            })
579            .collect();
580        let mut coeff_tt = Array1::<f64>::zeros(n);
581        let mut coeff_tl = Array1::<f64>::zeros(n);
582        let mut coeff_ll = Array1::<f64>::zeros(n);
583        for (i, (tt, tl, ll)) in rows.into_iter().enumerate() {
584            coeff_tt[i] = tt;
585            coeff_tl[i] = tl;
586            coeff_ll[i] = ll;
587        }
588        let h_tt = xt_diag_x_dense(x_t, &coeff_tt)?;
589        let h_tl = xt_diag_y_dense(x_t, &coeff_tl, x_ls)?;
590        let h_ll = xt_diag_x_dense(x_ls, &coeff_ll)?;
591        let mut h = Array2::<f64>::zeros((total, total));
592        h.slice_mut(s![0..pt, 0..pt]).assign(&h_tt);
593        h.slice_mut(s![0..pt, pt..total]).assign(&h_tl);
594        h.slice_mut(s![pt..total, pt..total]).assign(&h_ll);
595        mirror_upper_to_lower(&mut h);
596        Ok(Some(h))
597    }
598
599    pub(crate) fn expected_joint_information_for_specs(
600        &self,
601        block_states: &[ParameterBlockState],
602        specs: Option<&[ParameterBlockSpec]>,
603    ) -> Result<Option<Array2<f64>>, String> {
604        let Some((x_t, x_ls)) = self.exact_joint_dense_block_designs(specs)? else {
605            return Ok(None);
606        };
607        self.expected_joint_information_from_designs(block_states, &x_t, &x_ls)
608    }
609
610    pub(crate) fn expected_joint_information_directional_for_specs(
611        &self,
612        block_states: &[ParameterBlockState],
613        specs: Option<&[ParameterBlockSpec]>,
614        d_beta_flat: &Array1<f64>,
615    ) -> Result<Option<Array2<f64>>, String> {
616        let Some((x_t, x_ls)) = self.exact_joint_dense_block_designs(specs)? else {
617            return Ok(None);
618        };
619        self.expected_joint_information_directional_from_designs(
620            block_states,
621            &x_t,
622            &x_ls,
623            d_beta_flat,
624        )
625    }
626
627    pub(crate) fn expected_joint_information_second_directional_for_specs(
628        &self,
629        block_states: &[ParameterBlockState],
630        specs: Option<&[ParameterBlockSpec]>,
631        d_beta_u_flat: &Array1<f64>,
632        d_betav_flat: &Array1<f64>,
633    ) -> Result<Option<Array2<f64>>, String> {
634        let Some((x_t, x_ls)) = self.exact_joint_dense_block_designs(specs)? else {
635            return Ok(None);
636        };
637        self.expected_joint_information_second_directional_from_designs(
638            block_states,
639            &x_t,
640            &x_ls,
641            d_beta_u_flat,
642            d_betav_flat,
643        )
644    }
645
646    pub(crate) fn expected_joint_contracted_trace_hessian_for_specs(
647        &self,
648        block_states: &[ParameterBlockState],
649        specs: Option<&[ParameterBlockSpec]>,
650        trace_weight: &Array2<f64>,
651    ) -> Result<Option<Array2<f64>>, String> {
652        let Some((x_t, x_ls)) = self.exact_joint_dense_block_designs(specs)? else {
653            return Ok(None);
654        };
655        self.expected_joint_contracted_trace_hessian_from_designs(
656            block_states,
657            &x_t,
658            &x_ls,
659            trace_weight,
660        )
661    }
662
663    pub(crate) fn exact_newton_joint_psi_terms_for_specs(
664        &self,
665        block_states: &[ParameterBlockState],
666        specs: &[ParameterBlockSpec],
667        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
668        psi_index: usize,
669    ) -> Result<Option<crate::custom_family::ExactNewtonJointPsiTerms>, String> {
670        let Some((x_t, x_ls)) = self.exact_joint_dense_block_designs(Some(specs))? else {
671            return Ok(None);
672        };
673        self.exact_newton_joint_psi_terms_from_designs(
674            block_states,
675            specs,
676            derivative_blocks,
677            psi_index,
678            &x_t,
679            &x_ls,
680        )
681    }
682
683    pub(crate) fn exact_newton_joint_psisecond_order_terms_for_specs(
684        &self,
685        block_states: &[ParameterBlockState],
686        specs: &[ParameterBlockSpec],
687        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
688        psi_i: usize,
689        psi_j: usize,
690    ) -> Result<Option<crate::custom_family::ExactNewtonJointPsiSecondOrderTerms>, String> {
691        let Some((x_t, x_ls)) = self.exact_joint_dense_block_designs(Some(specs))? else {
692            return Ok(None);
693        };
694        self.exact_newton_joint_psisecond_order_terms_from_designs(
695            block_states,
696            derivative_blocks,
697            psi_i,
698            psi_j,
699            &x_t,
700            &x_ls,
701        )
702    }
703
704    pub(crate) fn exact_newton_joint_psihessian_directional_derivative_for_specs(
705        &self,
706        block_states: &[ParameterBlockState],
707        specs: &[ParameterBlockSpec],
708        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
709        psi_index: usize,
710        d_beta_flat: &Array1<f64>,
711    ) -> Result<Option<Array2<f64>>, String> {
712        let Some((x_t, x_ls)) = self.exact_joint_dense_block_designs(Some(specs))? else {
713            return Ok(None);
714        };
715        self.exact_newton_joint_psihessian_directional_derivative_from_designs(
716            block_states,
717            derivative_blocks,
718            psi_index,
719            d_beta_flat,
720            &x_t,
721            &x_ls,
722        )
723    }
724
725    /// Compute the rowwise joint curvature coefficients (D_tt, D_tl, D_ll)
726    /// shared by the dense joint Hessian path and the matrix-free workspace.
727    pub(crate) fn exact_newton_joint_hessian_row_coefficients(
728        &self,
729        block_states: &[ParameterBlockState],
730    ) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>), String> {
731        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
732        let n = self.y.len();
733        let eta_t = &block_states[Self::BLOCK_T].eta;
734        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
735        if eta_t.len() != n || eta_ls.len() != n || self.weights.len() != n {
736            return Err(GamlssError::DimensionMismatch {
737                reason: "BinomialLocationScaleFamily input size mismatch".to_string(),
738            }
739            .into());
740        }
741
742        let core = binomial_location_scale_core(
743            &self.y,
744            &self.weights,
745            eta_t,
746            eta_ls,
747            None,
748            &self.link_kind,
749        )?;
750        let mut coeff_tt = vec![0.0_f64; n];
751        let mut coeff_tl = vec![0.0_f64; n];
752        let mut coeff_ll = vec![0.0_f64; n];
753        let y_slice = self.y.as_slice().expect("y must be contiguous");
754        let w_slice = self.weights.as_slice().expect("weights must be contiguous");
755        let q0_slice = core.q0.as_slice().expect("q0 must be contiguous");
756        let sigma_slice = core.sigma.as_slice().expect("sigma must be contiguous");
757        let dsigma_slice = core
758            .dsigma_deta
759            .as_slice()
760            .expect("dsigma_deta must be contiguous");
761        let mu_slice = core.mu.as_slice().expect("mu must be contiguous");
762        let dmu_slice = core.dmu_dq.as_slice().expect("dmu_dq must be contiguous");
763        let d2mu_slice = core
764            .d2mu_dq2
765            .as_slice()
766            .expect("d2mu_dq2 must be contiguous");
767        let d3mu_slice = core
768            .d3mu_dq3
769            .as_slice()
770            .expect("d3mu_dq3 must be contiguous");
771        let link_kind = &self.link_kind;
772        coeff_tt
773            .par_iter_mut()
774            .zip(coeff_tl.par_iter_mut())
775            .zip(coeff_ll.par_iter_mut())
776            .enumerate()
777            .for_each(|(i, ((c_tt, c_tl), c_ll))| {
778                let q = q0_slice[i];
779                let r = 1.0 / sigma_slice[i];
780                let kappa = dsigma_slice[i] / sigma_slice[i];
781                let (m1, m2, _) = binomial_neglog_q_derivatives_dispatch(
782                    y_slice[i],
783                    w_slice[i],
784                    q,
785                    mu_slice[i],
786                    dmu_slice[i],
787                    d2mu_slice[i],
788                    d3mu_slice[i],
789                    link_kind,
790                );
791                *c_tt = m2 * r * r;
792                *c_tl = kappa * r * (m1 + q * m2);
793                *c_ll = kappa * kappa * q * (m1 + q * m2);
794            });
795        Ok((
796            Array1::from_vec(coeff_tt),
797            Array1::from_vec(coeff_tl),
798            Array1::from_vec(coeff_ll),
799        ))
800    }
801
802    /// Exact diagonal-block-only Hessians (h_tt, h_ll) used by `evaluate()`
803    /// to populate per-block working sets without ever materializing the
804    /// dense p×p joint matrix.
805    pub(crate) fn exact_newton_block_diagonal_hessians_from_design_matrices(
806        &self,
807        block_states: &[ParameterBlockState],
808        x_t: &DesignMatrix,
809        x_ls: &DesignMatrix,
810    ) -> Result<(Array2<f64>, Array2<f64>), String> {
811        let (coeff_tt, _coeff_tl, coeff_ll) =
812            self.exact_newton_joint_hessian_row_coefficients(block_states)?;
813        let h_tt = xt_diag_x_design(x_t, &coeff_tt)?;
814        let h_ll = xt_diag_x_design(x_ls, &coeff_ll)?;
815        Ok((h_tt, h_ll))
816    }
817
818    pub(crate) fn exact_newton_joint_hessian_from_designs(
819        &self,
820        block_states: &[ParameterBlockState],
821        x_t: &Array2<f64>,
822        x_ls: &Array2<f64>,
823    ) -> Result<Option<Array2<f64>>, String> {
824        // Exact joint coefficient-space Hessian for the probit, non-wiggle
825        // location-scale family.
826        //
827        // At the fitted mode, the correct joint outer smoothing sensitivity is
828        //
829        //   H u_k = -g_k,
830        //   g_k = A_k beta,
831        //
832        // so the solve must use the full joint working-curvature matrix `H`.
833        // For this family the likelihood is coupled through
834        //
835        //   q = -eta_t * exp(-eta_ls),
836        //
837        // so the threshold and log-sigma blocks are not independent even if
838        // the penalties are block-diagonal.
839        //
840        // Write for row i
841        //
842        //   t_i = x_i^T beta_t,
843        //   s_i = z_i^T beta_ls,
844        //   r_i = exp(-s_i),
845        //   q_i = -t_i r_i,
846        //   F_i(q) = -w_i [ y_i log Phi(q) + (1-y_i) log(1-Phi(q)) ].
847        //
848        // Let
849        //
850        //   m1_i = F_i'(q_i),
851        //   m2_i = F_i''(q_i).
852        //
853        // The q-derivatives with respect to the two predictors are
854        //
855        //   q_t  = -r,
856        //   q_ls = -q,
857        //   q_tt = 0,
858        //   q_t,ls = r,
859        //   q_ls,ls = q.
860        //
861        // For any scalar-composition objective G(t,s)=F(q(t,s)), the Hessian
862        // coefficients are
863        //
864        //   G_ab = m2 q_a q_b + m1 q_ab.
865        //
866        // Therefore the exact rowwise joint curvature in (eta_t, eta_ls) is
867        //
868        //   coeff_tt = m2 r^2,
869        //   coeff_t,ls = r (m1 + q m2),
870        //   coeff_ls,ls = q (m1 + q m2),
871        //
872        // and the full joint coefficient-space Hessian is assembled as
873        //
874        //   H_tt    = X_t^T diag(coeff_tt)    X_t,
875        //   H_t,ls  = X_t^T diag(coeff_t,ls)  X_ls,
876        //   H_ls,ls = X_ls^T diag(coeff_ls,ls) X_ls.
877        //
878        // The off-diagonal block is generally nonzero. That is exactly the
879        // coupling term the broken blockwise outer-gradient path was dropping.
880        let (coeff_tt, coeff_tl, coeff_ll) =
881            self.exact_newton_joint_hessian_row_coefficients(block_states)?;
882        let pt = x_t.ncols();
883        let pls = x_ls.ncols();
884
885        let h_tt = xt_diag_x_dense(x_t, &coeff_tt)?;
886        let h_tl = xt_diag_y_dense(x_t, &coeff_tl, x_ls)?;
887        let h_ll = xt_diag_x_dense(x_ls, &coeff_ll)?;
888        let total = pt + pls;
889        let mut h = Array2::<f64>::zeros((total, total));
890        h.slice_mut(s![0..pt, 0..pt]).assign(&h_tt);
891        h.slice_mut(s![0..pt, pt..total]).assign(&h_tl);
892        h.slice_mut(s![pt..total, pt..total]).assign(&h_ll);
893        mirror_upper_to_lower(&mut h);
894        Ok(Some(h))
895    }
896
897    pub(crate) fn exact_newton_joint_hessian_from_design_matrices(
898        &self,
899        block_states: &[ParameterBlockState],
900        x_t: &DesignMatrix,
901        x_ls: &DesignMatrix,
902    ) -> Result<Option<Array2<f64>>, String> {
903        if let (Some(x_t_dense), Some(x_ls_dense)) = (x_t.as_dense_ref(), x_ls.as_dense_ref()) {
904            return self.exact_newton_joint_hessian_from_designs(
905                block_states,
906                x_t_dense,
907                x_ls_dense,
908            );
909        }
910        let (coeff_tt, coeff_tl, coeff_ll) =
911            self.exact_newton_joint_hessian_row_coefficients(block_states)?;
912        let pt = x_t.ncols();
913        let pls = x_ls.ncols();
914
915        let h_tt = xt_diag_x_design(x_t, &coeff_tt)?;
916        let h_tl = xt_diag_y_design(x_t, &coeff_tl, x_ls)?;
917        let h_ll = xt_diag_x_design(x_ls, &coeff_ll)?;
918        let total = pt + pls;
919        let mut h = Array2::<f64>::zeros((total, total));
920        h.slice_mut(s![0..pt, 0..pt]).assign(&h_tt);
921        h.slice_mut(s![0..pt, pt..total]).assign(&h_tl);
922        h.slice_mut(s![pt..total, pt..total]).assign(&h_ll);
923        mirror_upper_to_lower(&mut h);
924        Ok(Some(h))
925    }
926
927    pub(crate) fn exact_newton_joint_hessian_directional_derivative_from_designs(
928        &self,
929        block_states: &[ParameterBlockState],
930        x_t: &Array2<f64>,
931        x_ls: &Array2<f64>,
932        d_beta_flat: &Array1<f64>,
933    ) -> Result<Option<Array2<f64>>, String> {
934        // Exact first directional derivative D_beta H_L[u] of the joint
935        // likelihood curvature.
936        //
937        // Write
938        //
939        //   t  = X_t beta_t,
940        //   ls = X_ls beta_ls,
941        //   s  = exp(-ls),
942        //   q  = -t .* s.
943        //
944        // For a full coefficient-space direction
945        //
946        //   u = (u_t, u_ls),
947        //   xi_t  = X_t u_t,
948        //   xi_ls = X_ls u_ls,
949        //
950        // the induced q-direction is
951        //
952        //   alpha = D q[u] = -s .* xi_t - q .* xi_ls.
953        //
954        // The joint diagonal-working-curvature likelihood matrix is
955        //
956        //   H_L = J^T W J,
957        //   J_t  = -diag(s) X_t,
958        //   J_ls = -diag(q) X_ls.
959        //
960        // Differentiating once gives
961        //
962        //   D_beta H_L[u]
963        //   = K[u]^T W J
964        //     + J^T W K[u]
965        //     + J^T diag(nu .* alpha) J,
966        //
967        // where
968        //
969        //   K_t[u]  = diag(s .* xi_ls) X_t,
970        //   K_ls[u] = diag(s .* xi_t + q .* xi_ls) X_ls,
971        //
972        // and `nu = d'''(q)` is the third derivative of the scalar row loss.
973        // This is exactly the joint curvature drift that enters the profiled
974        // derivative through
975        //
976        //   dot H_k = A_k + D_beta H_L[u_k],
977        //   dJ/drho_k
978        //   = 0.5 beta^T A_k beta
979        //     + 0.5 tr(H^{-1} dot H_k)
980        //     - 0.5 tr(S^+ A_k).
981        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
982        let n = self.y.len();
983        let eta_t = &block_states[Self::BLOCK_T].eta;
984        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
985        if eta_t.len() != n || eta_ls.len() != n || self.weights.len() != n {
986            return Err(GamlssError::DimensionMismatch {
987                reason: "BinomialLocationScaleFamily input size mismatch".to_string(),
988            }
989            .into());
990        }
991
992        let pt = x_t.ncols();
993        let pls = x_ls.ncols();
994        if d_beta_flat.len() != pt + pls {
995            return Err(GamlssError::DimensionMismatch {
996                reason: format!(
997                    "BinomialLocationScaleFamily joint d_beta length mismatch: got {}, expected {}",
998                    d_beta_flat.len(),
999                    pt + pls
1000                ),
1001            }
1002            .into());
1003        }
1004        let d_eta_t = fast_av(x_t, &d_beta_flat.slice(s![0..pt]));
1005        let d_eta_ls = fast_av(x_ls, &d_beta_flat.slice(s![pt..pt + pls]));
1006        let core = binomial_location_scale_core(
1007            &self.y,
1008            &self.weights,
1009            eta_t,
1010            eta_ls,
1011            None,
1012            &self.link_kind,
1013        )?;
1014        let (coeff_tt, coeff_tl, coeff_ll) =
1015            binomial_location_scale_first_directional_coefficients(
1016                &self.y,
1017                &self.weights,
1018                &core,
1019                &d_eta_t,
1020                &d_eta_ls,
1021                &self.link_kind,
1022            )?;
1023
1024        let d_h_tt = xt_diag_x_dense(x_t, &coeff_tt)?;
1025        let d_h_tl = xt_diag_y_dense(x_t, &coeff_tl, x_ls)?;
1026        let d_h_ll = xt_diag_x_dense(x_ls, &coeff_ll)?;
1027        let total = pt + pls;
1028        let mut d_h = Array2::<f64>::zeros((total, total));
1029        d_h.slice_mut(s![0..pt, 0..pt]).assign(&d_h_tt);
1030        d_h.slice_mut(s![0..pt, pt..total]).assign(&d_h_tl);
1031        d_h.slice_mut(s![pt..total, pt..total]).assign(&d_h_ll);
1032        mirror_upper_to_lower(&mut d_h);
1033        Ok(Some(d_h))
1034    }
1035
1036    pub(crate) fn exact_newton_joint_hessiansecond_directional_derivative_from_designs(
1037        &self,
1038        block_states: &[ParameterBlockState],
1039        x_t: &Array2<f64>,
1040        x_ls: &Array2<f64>,
1041        d_beta_u_flat: &Array1<f64>,
1042        d_betav_flat: &Array1<f64>,
1043    ) -> Result<Option<Array2<f64>>, String> {
1044        // Exact mixed second directional derivative D_beta^2 H_L[u, v].
1045        //
1046        // This is the family-specific part of the total second curvature drift
1047        //
1048        //   ddot H_{k,l}
1049        //   = B_{k,l}
1050        //     + D_beta H_L[u_{k,l}]
1051        //     + D_beta^2 H_L[u_l, u_k],
1052        //
1053        // used in the profiled outer Hessian
1054        //
1055        //   d^2J/(drho_k drho_l)
1056        //   = u_l^T A_k beta
1057        //     + 0.5 beta^T B_{k,l} beta
1058        //     + 0.5 tr(H^{-1} ddot H_{k,l})
1059        //     - 0.5 tr(H^{-1} dot H_l H^{-1} dot H_k)
1060        //     - 0.5 d^2/drho_k drho_l log|S|_+.
1061        //
1062        // For directions
1063        //
1064        //   u = (u_t, u_ls),  v = (v_t, v_ls),
1065        //
1066        // define the rowwise predictor perturbations
1067        //
1068        //   xi_t^(u)  = X_t u_t,    xi_ls^(u)  = X_ls u_ls,
1069        //   xi_t^(v)  = X_t v_t,    xi_ls^(v)  = X_ls v_ls.
1070        //
1071        // With the exact exp sigma link,
1072        //
1073        //   s = exp(-eta_ls),
1074        //   q = -eta_t .* s,
1075        //
1076        // the first and second q-drifts are
1077        //
1078        //   alpha(u)   = D q[u]   = -s .* xi_t^(u) - q .* xi_ls^(u),
1079        //   alpha(v)   = D q[v]   = -s .* xi_t^(v) - q .* xi_ls^(v),
1080        //   alpha(u,v) = D^2 q[u,v]
1081        //              = s .* (xi_t^(u) .* xi_ls^(v) + xi_t^(v) .* xi_ls^(u))
1082        //                + q .* xi_ls^(u) .* xi_ls^(v).
1083        //
1084        // Differentiating the scalar-composition Hessian coefficients twice
1085        // yields the rowwise formulas below. Those formulas are exactly the
1086        // fourth-order beta-curvature contraction needed to make the joint
1087        // rho-Hessian path consistent with the first-order joint solve.
1088        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
1089        let n = self.y.len();
1090        let eta_t = &block_states[Self::BLOCK_T].eta;
1091        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
1092        if eta_t.len() != n || eta_ls.len() != n || self.weights.len() != n {
1093            return Err(GamlssError::DimensionMismatch {
1094                reason: "BinomialLocationScaleFamily input size mismatch".to_string(),
1095            }
1096            .into());
1097        }
1098
1099        let pt = x_t.ncols();
1100        let pls = x_ls.ncols();
1101        let total = pt + pls;
1102        if d_beta_u_flat.len() != total {
1103            return Err(GamlssError::DimensionMismatch { reason: format!(
1104                "BinomialLocationScaleFamily joint d_beta_u length mismatch: got {}, expected {}",
1105                d_beta_u_flat.len(),
1106                total
1107            ) }.into());
1108        }
1109        if d_betav_flat.len() != total {
1110            return Err(GamlssError::DimensionMismatch { reason: format!(
1111                "BinomialLocationScaleFamily joint d_betav length mismatch: got {}, expected {}",
1112                d_betav_flat.len(),
1113                total
1114            ) }.into());
1115        }
1116        let d_eta_t_u = fast_av(x_t, &d_beta_u_flat.slice(s![0..pt]));
1117        let d_eta_ls_u = fast_av(x_ls, &d_beta_u_flat.slice(s![pt..total]));
1118        let d_eta_tv = fast_av(x_t, &d_betav_flat.slice(s![0..pt]));
1119        let d_eta_lsv = fast_av(x_ls, &d_betav_flat.slice(s![pt..total]));
1120        let core = binomial_location_scale_core(
1121            &self.y,
1122            &self.weights,
1123            eta_t,
1124            eta_ls,
1125            None,
1126            &self.link_kind,
1127        )?;
1128        let (coeff_tt, coeff_tl, coeff_ll) =
1129            binomial_location_scalesecond_directional_coefficients(
1130                &self.y,
1131                &self.weights,
1132                &core,
1133                &d_eta_t_u,
1134                &d_eta_ls_u,
1135                &d_eta_tv,
1136                &d_eta_lsv,
1137                &self.link_kind,
1138            )?;
1139
1140        let d2_h_tt = xt_diag_x_dense(x_t, &coeff_tt)?;
1141        let d2_h_tl = xt_diag_y_dense(x_t, &coeff_tl, x_ls)?;
1142        let d2_h_ll = xt_diag_x_dense(x_ls, &coeff_ll)?;
1143        let mut d2_h = Array2::<f64>::zeros((total, total));
1144        d2_h.slice_mut(s![0..pt, 0..pt]).assign(&d2_h_tt);
1145        d2_h.slice_mut(s![0..pt, pt..total]).assign(&d2_h_tl);
1146        d2_h.slice_mut(s![pt..total, pt..total]).assign(&d2_h_ll);
1147        mirror_upper_to_lower(&mut d2_h);
1148        Ok(Some(d2_h))
1149    }
1150
1151    pub(crate) fn exact_newton_joint_psi_direction(
1152        &self,
1153        block_states: &[ParameterBlockState],
1154        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
1155        psi_index: usize,
1156        x_t: &Array2<f64>,
1157        x_ls: &Array2<f64>,
1158        policy: &gam_runtime::resource::ResourcePolicy,
1159    ) -> Result<Option<LocationScaleJointPsiDirection>, String> {
1160        let Some(parts) = locscale_joint_psi_direction_parts(
1161            block_states,
1162            derivative_blocks,
1163            psi_index,
1164            self.y.len(),
1165            x_t.ncols(),
1166            x_ls.ncols(),
1167            Self::BLOCK_T,
1168            Self::BLOCK_LOG_SIGMA,
1169            2,
1170            "BinomialLocationScaleFamily",
1171            "threshold",
1172            policy,
1173        )?
1174        else {
1175            return Ok(None);
1176        };
1177        Ok(Some(LocationScaleJointPsiDirection {
1178            block_idx: parts.block_idx,
1179            local_idx: parts.local_idx,
1180            x_primary_psi: parts.primary_psi,
1181            x_ls_psi: parts.log_sigma_psi,
1182            z_primary_psi: parts.primary_z,
1183            z_ls_psi: parts.log_sigma_z,
1184        }))
1185    }
1186
1187    pub(crate) fn exact_newton_joint_psisecond_design_drifts(
1188        &self,
1189        block_states: &[ParameterBlockState],
1190        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
1191        psi_a: &LocationScaleJointPsiDirection,
1192        psi_b: &LocationScaleJointPsiDirection,
1193        x_t: &Array2<f64>,
1194        x_ls: &Array2<f64>,
1195    ) -> Result<LocationScaleJointPsiSecondDrifts, String> {
1196        locscale_joint_psisecond_design_drifts(
1197            block_states,
1198            derivative_blocks,
1199            psi_a,
1200            psi_b,
1201            LocScalePsiDriftConfig {
1202                n: self.y.len(),
1203                p_primary: x_t.ncols(),
1204                p_log_sigma: x_ls.ncols(),
1205                primary_block_idx: Self::BLOCK_T,
1206                log_sigma_block_idx: Self::BLOCK_LOG_SIGMA,
1207                family_name: "BinomialLocationScaleFamily",
1208                primary_label: "threshold",
1209                policy: &self.policy,
1210            },
1211        )
1212    }
1213
1214    pub(crate) fn exact_newton_joint_psi_terms_from_designs(
1215        &self,
1216        block_states: &[ParameterBlockState],
1217        specs: &[ParameterBlockSpec],
1218        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
1219        psi_index: usize,
1220        x_t: &Array2<f64>,
1221        x_ls: &Array2<f64>,
1222    ) -> Result<Option<crate::custom_family::ExactNewtonJointPsiTerms>, String> {
1223        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
1224        if specs.len() != 2 || derivative_blocks.len() != 2 {
1225            return Err(GamlssError::DimensionMismatch { reason: format!(
1226                "BinomialLocationScaleFamily joint psi terms expect 2 specs and 2 derivative blocks, got {} and {}",
1227                specs.len(),
1228                derivative_blocks.len()
1229            ) }.into());
1230        }
1231        let n = self.y.len();
1232        let eta_t = &block_states[Self::BLOCK_T].eta;
1233        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
1234        if eta_t.len() != n || eta_ls.len() != n || self.weights.len() != n {
1235            return Err(GamlssError::DimensionMismatch {
1236                reason: "BinomialLocationScaleFamily input size mismatch".to_string(),
1237            }
1238            .into());
1239        }
1240
1241        // Joint fixed-beta psi terms for the coupled 2-block probit model.
1242        //
1243        // We work over the flattened coefficient vector beta = [beta_t; beta_ls]
1244        // and one realized spatial coordinate psi_a. The exact profiled/Laplace
1245        // outer calculus needs the family-side explicit objects
1246        //
1247        //   V_psi^explicit,  g_psi^explicit,  H_psi^explicit,
1248        //
1249        // all in this flattened coefficient space. These are likelihood-only
1250        // objects:
1251        //
1252        //   D_psi, D_{beta psi}, D_{beta beta psi}
1253        //
1254        // Generic exact-joint code adds the realized penalty motion
1255        //
1256        //   0.5 beta^T S_psi beta,  S_psi beta,  S_psi
1257        //
1258        // when forming V_i, g_i, H_i. Keeping the family hook likelihood-only
1259        // is what makes the unified S(theta) outer calculus correct for both
1260        // psi-moving designs and psi-moving penalties.
1261        //
1262        // Model:
1263        //   eta_t  = X_t beta_t,
1264        //   eta_ls = X_ls beta_ls,
1265        //   r      = exp(-eta_ls),
1266        //   q      = -eta_t .* r.
1267        //
1268        // A single realized psi_a may move either block design, so define the
1269        // fixed-beta predictor drifts
1270        //
1271        //   z_t  = X_{t,psi}  beta_t   (zero if psi_a is not a threshold psi)
1272        //   z_ls = X_{ls,psi} beta_ls  (zero if psi_a is not a log-sigma psi).
1273        //
1274        // Then the explicit q-drift is
1275        //
1276        //   q_psi = -r .* z_t - q .* z_ls.
1277        //
1278        // Rowwise scalar derivatives of the negative Bernoulli-probit loss are
1279        //
1280        //   a = dF/dq,
1281        //   b = d²F/dq²,
1282        //   c = d³F/dq³.
1283        //
1284        // Predictor-space score pieces:
1285        //
1286        //   r_t  = dF/deta_t  = -a r,
1287        //   r_ls = dF/deta_ls = -a q.
1288        //
1289        // Their explicit psi derivatives at fixed beta are
1290        //
1291        //   d_psi r_t  = -b q_psi r + a r z_ls,
1292        //   d_psi r_ls = -(a + q b) q_psi.
1293        //
1294        // Hence the exact joint score derivative is
1295        //
1296        //   g_psi
1297        //   = [ X_{t,psi}^T r_t  + X_t^T d_psi r_t,
1298        //       X_{ls,psi}^T r_ls + X_ls^T d_psi r_ls ].
1299        //
1300        // The exact envelope term is
1301        //
1302        //   V_psi^explicit = r_t^T z_t + r_ls^T z_ls.
1303        //
1304        // For the Laplace trace we also need the explicit Hessian drift. The
1305        // joint exact Hessian has block coefficients
1306        //
1307        //   h_tt = b r²,
1308        //   h_tl = r (a + q b),
1309        //   h_ll = q (a + q b),
1310        //
1311        // so differentiating those coefficients at fixed beta gives
1312        //
1313        //   d_psi h_tt = r² (c q_psi - 2 b z_ls),
1314        //   d_psi h_tl = r [ (2 b + c q) q_psi - (a + q b) z_ls ],
1315        //   d_psi h_ll = (a + 3 q b + q² c) q_psi.
1316        //
1317        // The full joint explicit Hessian drift is then
1318        //
1319        //   H_tt,psi
1320        //   = X_{t,psi}^T diag(h_tt) X_t
1321        //     + X_t^T diag(h_tt) X_{t,psi}
1322        //     + X_t^T diag(d_psi h_tt) X_t,
1323        //
1324        //   H_tl,psi
1325        //   = X_{t,psi}^T diag(h_tl) X_ls
1326        //     + X_t^T diag(h_tl) X_{ls,psi}
1327        //     + X_t^T diag(d_psi h_tl) X_ls,
1328        //
1329        //   H_ll,psi
1330        //   = X_{ls,psi}^T diag(h_ll) X_ls
1331        //     + X_ls^T diag(h_ll) X_{ls,psi}
1332        //     + X_ls^T diag(d_psi h_ll) X_ls.
1333        //
1334        // Even when only one block moves explicitly, the resulting score and
1335        // Hessian objects are joint because q couples eta_t and eta_ls.
1336        let core = binomial_location_scale_core(
1337            &self.y,
1338            &self.weights,
1339            eta_t,
1340            eta_ls,
1341            None,
1342            &self.link_kind,
1343        )?;
1344        let pt = x_t.ncols();
1345        let pls = x_ls.ncols();
1346        let total = pt + pls;
1347        let Some(dir_a) = self.exact_newton_joint_psi_direction(
1348            block_states,
1349            derivative_blocks,
1350            psi_index,
1351            x_t,
1352            x_ls,
1353            &self.policy,
1354        )?
1355        else {
1356            return Ok(None);
1357        };
1358        let (z_t, z_ls) = (&dir_a.z_primary_psi, &dir_a.z_ls_psi);
1359
1360        // Per-row scalars assembled in parallel. The probit/inverse-link
1361        // derivatives are O(n) at large scale and are called O(K) times per
1362        // outer REML gradient (K = number of psi coords), so a parallel pass is
1363        // worthwhile here.
1364        struct PsiTermsRow {
1365            pub(crate) r_t: f64,
1366            pub(crate) r_ls: f64,
1367            pub(crate) dr_t: f64,
1368            pub(crate) dr_ls: f64,
1369            pub(crate) h_tt: f64,
1370            pub(crate) h_tl: f64,
1371            pub(crate) h_ll: f64,
1372            pub(crate) dh_tt: f64,
1373            pub(crate) dh_tl: f64,
1374            pub(crate) dh_ll: f64,
1375            pub(crate) obj: f64,
1376        }
1377        let y_p = self.y.as_slice().expect("y must be contiguous");
1378        let w_p = self.weights.as_slice().expect("weights must be contiguous");
1379        let q0_p = core.q0.as_slice().expect("q0 must be contiguous");
1380        let sigma_p = core.sigma.as_slice().expect("sigma must be contiguous");
1381        let dsigma_p = core
1382            .dsigma_deta
1383            .as_slice()
1384            .expect("dsigma_deta must be contiguous");
1385        let mu_p = core.mu.as_slice().expect("mu must be contiguous");
1386        let dmu_p = core.dmu_dq.as_slice().expect("dmu_dq must be contiguous");
1387        let d2mu_p = core
1388            .d2mu_dq2
1389            .as_slice()
1390            .expect("d2mu_dq2 must be contiguous");
1391        let d3mu_p = core
1392            .d3mu_dq3
1393            .as_slice()
1394            .expect("d3mu_dq3 must be contiguous");
1395        let z_t_p = z_t.as_slice().expect("z_t must be contiguous");
1396        let z_ls_p = z_ls.as_slice().expect("z_ls must be contiguous");
1397        let link_kind_p = &self.link_kind;
1398        let rows: Vec<PsiTermsRow> = (0..n)
1399            .into_par_iter()
1400            .map(|i| {
1401                let q = q0_p[i];
1402                let r = 1.0 / sigma_p[i];
1403                let s = dsigma_p[i] / sigma_p[i];
1404                let sz = s * z_ls_p[i];
1405                let q_psi = -r * z_t_p[i] - q * sz;
1406                let (a, b, c) = binomial_neglog_q_derivatives_dispatch(
1407                    y_p[i],
1408                    w_p[i],
1409                    q,
1410                    mu_p[i],
1411                    dmu_p[i],
1412                    d2mu_p[i],
1413                    d3mu_p[i],
1414                    link_kind_p,
1415                );
1416                let r_t = -a * r;
1417                let r_ls = -a * q * s;
1418                PsiTermsRow {
1419                    r_t,
1420                    r_ls,
1421                    dr_t: -b * q_psi * r + a * r * sz,
1422                    dr_ls: -(a + q * b) * q_psi,
1423                    h_tt: b * r * r,
1424                    h_tl: r * (a + q * b),
1425                    h_ll: q * (a + q * b),
1426                    dh_tt: r * r * (c * q_psi - 2.0 * b * sz),
1427                    dh_tl: r * ((2.0 * b + c * q) * q_psi - (a + q * b) * sz),
1428                    dh_ll: (a + 3.0 * q * b + q * q * c) * q_psi,
1429                    obj: r_t * z_t_p[i] + r_ls * z_ls_p[i],
1430                }
1431            })
1432            .collect();
1433        let mut r_t = Array1::<f64>::zeros(n);
1434        let mut r_ls = Array1::<f64>::zeros(n);
1435        let mut dr_t = Array1::<f64>::zeros(n);
1436        let mut dr_ls = Array1::<f64>::zeros(n);
1437        let mut h_tt = Array1::<f64>::zeros(n);
1438        let mut h_tl = Array1::<f64>::zeros(n);
1439        let mut h_ll = Array1::<f64>::zeros(n);
1440        let mut dh_tt = Array1::<f64>::zeros(n);
1441        let mut dh_tl = Array1::<f64>::zeros(n);
1442        let mut dh_ll = Array1::<f64>::zeros(n);
1443        let mut objective_psi = 0.0_f64;
1444        for (i, row) in rows.into_iter().enumerate() {
1445            r_t[i] = row.r_t;
1446            r_ls[i] = row.r_ls;
1447            dr_t[i] = row.dr_t;
1448            dr_ls[i] = row.dr_ls;
1449            h_tt[i] = row.h_tt;
1450            h_tl[i] = row.h_tl;
1451            h_ll[i] = row.h_ll;
1452            dh_tt[i] = row.dh_tt;
1453            dh_tl[i] = row.dh_tl;
1454            dh_ll[i] = row.dh_ll;
1455            objective_psi += row.obj;
1456        }
1457
1458        let hessian_psi_operator = build_two_block_custom_family_joint_psi_operator_from_actions(
1459            dir_a.x_primary_psi.cloned_first_action(),
1460            dir_a.x_ls_psi.cloned_first_action(),
1461            0..pt,
1462            pt..pt + pls,
1463            x_t,
1464            x_ls,
1465            &h_tt,
1466            &h_tl,
1467            &h_ll,
1468            &dh_tt,
1469            &dh_tl,
1470            &dh_ll,
1471        )?;
1472        let x_t_map = dir_a.x_primary_psi.as_linear_map_ref();
1473        let x_ls_map = dir_a.x_ls_psi.as_linear_map_ref();
1474        let score_t = x_t_map.transpose_mul(r_t.view()) + fast_atv(x_t, &dr_t);
1475        let score_ls = x_ls_map.transpose_mul(r_ls.view()) + fast_atv(x_ls, &dr_ls);
1476        let mut score_psi = Array1::<f64>::zeros(total);
1477        score_psi.slice_mut(s![0..pt]).assign(&score_t);
1478        score_psi.slice_mut(s![pt..pt + pls]).assign(&score_ls);
1479        let hessian_psi = if hessian_psi_operator.is_some() {
1480            Array2::zeros((0, 0))
1481        } else {
1482            let h_tt_block = weighted_crossprod_psi_maps(
1483                x_t_map,
1484                h_tt.view(),
1485                CustomFamilyPsiLinearMapRef::Dense(x_t),
1486            )? + &weighted_crossprod_psi_maps(
1487                CustomFamilyPsiLinearMapRef::Dense(x_t),
1488                h_tt.view(),
1489                x_t_map,
1490            )? + &xt_diag_x_dense(x_t, &dh_tt)?;
1491            let h_tl_block = weighted_crossprod_psi_maps(
1492                x_t_map,
1493                h_tl.view(),
1494                CustomFamilyPsiLinearMapRef::Dense(x_ls),
1495            )? + &weighted_crossprod_psi_maps(
1496                CustomFamilyPsiLinearMapRef::Dense(x_t),
1497                h_tl.view(),
1498                x_ls_map,
1499            )? + &xt_diag_y_dense(x_t, &dh_tl, x_ls)?;
1500            let h_ll_block = weighted_crossprod_psi_maps(
1501                x_ls_map,
1502                h_ll.view(),
1503                CustomFamilyPsiLinearMapRef::Dense(x_ls),
1504            )? + &weighted_crossprod_psi_maps(
1505                CustomFamilyPsiLinearMapRef::Dense(x_ls),
1506                h_ll.view(),
1507                x_ls_map,
1508            )? + &xt_diag_x_dense(x_ls, &dh_ll)?;
1509
1510            let mut hessian_psi = Array2::<f64>::zeros((total, total));
1511            hessian_psi.slice_mut(s![0..pt, 0..pt]).assign(&h_tt_block);
1512            hessian_psi
1513                .slice_mut(s![0..pt, pt..pt + pls])
1514                .assign(&h_tl_block);
1515            hessian_psi
1516                .slice_mut(s![pt..pt + pls, pt..pt + pls])
1517                .assign(&h_ll_block);
1518            mirror_upper_to_lower(&mut hessian_psi);
1519            hessian_psi
1520        };
1521
1522        Ok(Some(crate::custom_family::ExactNewtonJointPsiTerms {
1523            objective_psi,
1524            score_psi,
1525            hessian_psi,
1526            hessian_psi_operator,
1527        }))
1528    }
1529
1530    pub(crate) fn exact_newton_joint_psisecond_order_terms_from_designs(
1531        &self,
1532        block_states: &[ParameterBlockState],
1533        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
1534        psi_i: usize,
1535        psi_j: usize,
1536        x_t: &Array2<f64>,
1537        x_ls: &Array2<f64>,
1538    ) -> Result<Option<crate::custom_family::ExactNewtonJointPsiSecondOrderTerms>, String> {
1539        let Some(dir_i) = self.exact_newton_joint_psi_direction(
1540            block_states,
1541            derivative_blocks,
1542            psi_i,
1543            x_t,
1544            x_ls,
1545            &self.policy,
1546        )?
1547        else {
1548            return Ok(None);
1549        };
1550        let Some(dir_j) = self.exact_newton_joint_psi_direction(
1551            block_states,
1552            derivative_blocks,
1553            psi_j,
1554            x_t,
1555            x_ls,
1556            &self.policy,
1557        )?
1558        else {
1559            return Ok(None);
1560        };
1561        Ok(Some(
1562            self.exact_newton_joint_psisecond_order_terms_from_parts(
1563                block_states,
1564                derivative_blocks,
1565                &dir_i,
1566                &dir_j,
1567                x_t,
1568                x_ls,
1569            )?,
1570        ))
1571    }
1572
1573    pub(crate) fn exact_newton_joint_psisecond_order_terms_from_parts(
1574        &self,
1575        block_states: &[ParameterBlockState],
1576        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
1577        dir_i: &LocationScaleJointPsiDirection,
1578        dir_j: &LocationScaleJointPsiDirection,
1579        x_t: &Array2<f64>,
1580        x_ls: &Array2<f64>,
1581    ) -> Result<crate::custom_family::ExactNewtonJointPsiSecondOrderTerms, String> {
1582        let second_drifts = self.exact_newton_joint_psisecond_design_drifts(
1583            block_states,
1584            derivative_blocks,
1585            dir_i,
1586            dir_j,
1587            x_t,
1588            x_ls,
1589        )?;
1590        let n = self.y.len();
1591        let eta_t = &block_states[Self::BLOCK_T].eta;
1592        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
1593        let core = binomial_location_scale_core(
1594            &self.y,
1595            &self.weights,
1596            eta_t,
1597            eta_ls,
1598            None,
1599            &self.link_kind,
1600        )?;
1601        let pt = x_t.ncols();
1602        let pls = x_ls.ncols();
1603        let total = pt + pls;
1604        let x_t_i_map = dir_i.x_primary_psi.as_linear_map_ref();
1605        let x_t_j_map = dir_j.x_primary_psi.as_linear_map_ref();
1606        let x_ls_i_map = dir_i.x_ls_psi.as_linear_map_ref();
1607        let x_ls_j_map = dir_j.x_ls_psi.as_linear_map_ref();
1608        let x_t_ab_map = second_psi_linear_map(
1609            second_drifts.x_primary_ab_action.as_ref(),
1610            second_drifts.x_primary_ab.as_ref(),
1611            n,
1612            pt,
1613        );
1614        let x_ls_ab_map = second_psi_linear_map(
1615            second_drifts.x_ls_ab_action.as_ref(),
1616            second_drifts.x_ls_ab.as_ref(),
1617            n,
1618            pls,
1619        );
1620
1621        // Exact fixed-beta psi/psi terms for the coupled non-wiggle probit
1622        // family.
1623        //
1624        // For two realized spatial coordinates psi_a, psi_b define
1625        //
1626        //   z_t,a  = X_{t,a} beta_t,    z_ls,a  = X_{ls,a} beta_ls,
1627        //   z_t,b  = X_{t,b} beta_t,    z_ls,b  = X_{ls,b} beta_ls,
1628        //   z_t,ab = X_{t,ab} beta_t,   z_ls,ab = X_{ls,ab} beta_ls.
1629        //
1630        // On the smooth interior branch, with r = exp(-eta_ls) and q = -eta_t r,
1631        //
1632        //   q_a  = -r z_t,a - q z_ls,a,
1633        //   q_b  = -r z_t,b - q z_ls,b,
1634        //   q_ab = -r z_t,ab
1635        //          + r(z_t,a z_ls,b + z_t,b z_ls,a)
1636        //          + q(z_ls,a z_ls,b - z_ls,ab).
1637        //
1638        // For scalar row loss derivatives
1639        //
1640        //   a = dF/dq,  b = d²F/dq²,  c = d³F/dq³,  d = d⁴F/dq⁴,
1641        //
1642        // the exact fixed-beta psi/psi objects are
1643        //
1644        //   V_ab = sum [ a q_ab + b q_a q_b ],
1645        //
1646        //   g_ab = [ X_{t,ab}^T r_t + X_{t,a}^T d_b r_t + X_{t,b}^T d_a r_t + X_t^T d_ab r_t,
1647        //            X_{ls,ab}^T r_ls + X_{ls,a}^T d_b r_ls + X_{ls,b}^T d_a r_ls + X_ls^T d_ab r_ls ],
1648        //
1649        // where
1650        //
1651        //   r_t  = -a r,
1652        //   r_ls = -a q,
1653        //
1654        //   d_a r_t  = -b q_a r + a r z_ls,a,
1655        //   d_a r_ls = -(a + q b) q_a,
1656        //
1657        //   d_ab r_t
1658        //   = r[
1659        //       -c q_a q_b - b q_ab
1660        //       + b(q_a z_ls,b + q_b z_ls,a)
1661        //       - a z_ls,a z_ls,b
1662        //       + a z_ls,ab
1663        //     ],
1664        //
1665        //   d_ab r_ls
1666        //   = -[(2b + q c) q_a q_b + (a + q b) q_ab].
1667        //
1668        // The exact Hessian psi/psi drift comes from the second derivatives of
1669        // the joint Hessian coefficients. In the notation of the unified outer
1670        // calculus, these rowwise coefficient drifts are precisely the
1671        // likelihood-side pieces of
1672        //
1673        //   D_{beta beta psi_a psi_b},
1674        //
1675        // before the generic assembler adds any realized-penalty contribution
1676        //
1677        //   S_ab = partial_{psi_a psi_b} S(theta).
1678        //
1679        // So this helper returns likelihood-only
1680        //
1681        //   D_ab, D_{beta ab}, D_{beta beta ab},
1682        //
1683        // and the unified exact assembler in custom_family.rs forms
1684        //
1685        //   V_ab = D_ab + 0.5 beta^T S_ab beta,
1686        //   g_ab = D_{beta ab} + S_ab beta,
1687        //   H_ab = D_{beta beta ab} + S_ab.
1688        //
1689        // Once H_ab is known, the outer assembler combines it with the joint
1690        // mode responses beta_a, beta_b, beta_ab and the contractions
1691        //
1692        //   T_a[beta_b], T_b[beta_a], D_beta H[beta_ab], D_beta^2 H[beta_a, beta_b]
1693        //
1694        // to form
1695        //
1696        //   ddot H_ab
1697        //   = H_ab + T_a[beta_b] + T_b[beta_a]
1698        //     + D_beta H[beta_ab] + D_beta^2 H[beta_a, beta_b].
1699        //
1700        // That is why this helper computes only the fixed-beta psi/psi object:
1701        // the total profiled/Laplace Hessian drift is assembled generically in
1702        // custom_family.rs after the joint solves.
1703        //
1704        // Concretely, the rowwise coefficient identities below are
1705        //
1706        //   h_tt = b r²,
1707        //   h_tl = r(a + q b),
1708        //   h_ll = q(a + q b),
1709        //
1710        // namely
1711        //
1712        //   d_ab h_tt
1713        //   = r²[
1714        //       d q_a q_b + c q_ab
1715        //       - 2c(q_b z_ls,a + q_a z_ls,b)
1716        //       + 4b z_ls,a z_ls,b
1717        //       - 2b z_ls,ab
1718        //     ],
1719        //
1720        //   d_ab h_tl
1721        //   = r[
1722        //       ((3c + q d) q_b) q_a
1723        //       + (2b + q c) q_ab
1724        //       - (2b + q c)(q_b z_ls,a + q_a z_ls,b)
1725        //       + (a + q b)(z_ls,a z_ls,b - z_ls,ab)
1726        //     ],
1727        //
1728        //   d_ab h_ll
1729        //   = (4b + 5q c + q² d) q_a q_b
1730        //     + (a + 3q b + q² c) q_ab.
1731        //
1732        // Differentiating X^T diag(h) X twice then gives the explicit joint
1733        // psi/psi Hessian blocks.
1734        let mut r_t = Array1::<f64>::zeros(n);
1735        let mut r_ls = Array1::<f64>::zeros(n);
1736        let mut dr_t_i = Array1::<f64>::zeros(n);
1737        let mut dr_t_j = Array1::<f64>::zeros(n);
1738        let mut dr_ls_i = Array1::<f64>::zeros(n);
1739        let mut dr_ls_j = Array1::<f64>::zeros(n);
1740        let mut d2r_t = Array1::<f64>::zeros(n);
1741        let mut d2r_ls = Array1::<f64>::zeros(n);
1742        let mut h_tt = Array1::<f64>::zeros(n);
1743        let mut h_tl = Array1::<f64>::zeros(n);
1744        let mut h_ll = Array1::<f64>::zeros(n);
1745        let mut dh_tt_i = Array1::<f64>::zeros(n);
1746        let mut dh_tt_j = Array1::<f64>::zeros(n);
1747        let mut dh_tl_i = Array1::<f64>::zeros(n);
1748        let mut dh_tl_j = Array1::<f64>::zeros(n);
1749        let mut dh_ll_i = Array1::<f64>::zeros(n);
1750        let mut dh_ll_j = Array1::<f64>::zeros(n);
1751        let mut d2h_tt = Array1::<f64>::zeros(n);
1752        let mut d2h_tl = Array1::<f64>::zeros(n);
1753        let mut d2h_ll = Array1::<f64>::zeros(n);
1754        let mut objective_psi_psi = 0.0;
1755        struct PsiSecondRow {
1756            pub(crate) r_t: f64,
1757            pub(crate) r_ls: f64,
1758            pub(crate) dr_t_i: f64,
1759            pub(crate) dr_t_j: f64,
1760            pub(crate) dr_ls_i: f64,
1761            pub(crate) dr_ls_j: f64,
1762            pub(crate) d2r_t: f64,
1763            pub(crate) d2r_ls: f64,
1764            pub(crate) h_tt: f64,
1765            pub(crate) h_tl: f64,
1766            pub(crate) h_ll: f64,
1767            pub(crate) dh_tt_i: f64,
1768            pub(crate) dh_tt_j: f64,
1769            pub(crate) dh_tl_i: f64,
1770            pub(crate) dh_tl_j: f64,
1771            pub(crate) dh_ll_i: f64,
1772            pub(crate) dh_ll_j: f64,
1773            pub(crate) d2h_tt: f64,
1774            pub(crate) d2h_tl: f64,
1775            pub(crate) d2h_ll: f64,
1776            pub(crate) objective: f64,
1777        }
1778        let y_p = self.y.as_slice().expect("y must be contiguous");
1779        let w_p = self.weights.as_slice().expect("weights must be contiguous");
1780        let q_p = core.q0.as_slice().expect("q0 must be contiguous");
1781        let sigma_p = core.sigma.as_slice().expect("sigma must be contiguous");
1782        let mu_p = core.mu.as_slice().expect("mu must be contiguous");
1783        let dmu_p = core.dmu_dq.as_slice().expect("dmu_dq must be contiguous");
1784        let d2mu_p = core
1785            .d2mu_dq2
1786            .as_slice()
1787            .expect("d2mu_dq2 must be contiguous");
1788        let d3mu_p = core
1789            .d3mu_dq3
1790            .as_slice()
1791            .expect("d3mu_dq3 must be contiguous");
1792        let z_t_i = dir_i
1793            .z_primary_psi
1794            .as_slice()
1795            .expect("z_t_psi_i must be contiguous");
1796        let z_t_j = dir_j
1797            .z_primary_psi
1798            .as_slice()
1799            .expect("z_t_psi_j must be contiguous");
1800        let z_ls_i = dir_i
1801            .z_ls_psi
1802            .as_slice()
1803            .expect("z_ls_psi_i must be contiguous");
1804        let z_ls_j = dir_j
1805            .z_ls_psi
1806            .as_slice()
1807            .expect("z_ls_psi_j must be contiguous");
1808        let z_t_ab = second_drifts
1809            .z_primary_ab
1810            .as_slice()
1811            .expect("z_t_ab must be contiguous");
1812        let z_ls_ab = second_drifts
1813            .z_ls_ab
1814            .as_slice()
1815            .expect("z_ls_ab must be contiguous");
1816        let link_kind_p = &self.link_kind;
1817        let rows: Result<Vec<PsiSecondRow>, String> = (0..n)
1818            .into_par_iter()
1819            .map(|row| {
1820                let q = q_p[row];
1821                let r = 1.0 / sigma_p[row];
1822                let q_i = -r * z_t_i[row] - q * z_ls_i[row];
1823                let q_j = -r * z_t_j[row] - q * z_ls_j[row];
1824                let q_ij = -r * z_t_ab[row]
1825                    + r * (z_t_i[row] * z_ls_j[row] + z_t_j[row] * z_ls_i[row])
1826                    + q * (z_ls_i[row] * z_ls_j[row] - z_ls_ab[row]);
1827                let (a, b, c) = binomial_neglog_q_derivatives_dispatch(
1828                    y_p[row],
1829                    w_p[row],
1830                    q,
1831                    mu_p[row],
1832                    dmu_p[row],
1833                    d2mu_p[row],
1834                    d3mu_p[row],
1835                    link_kind_p,
1836                );
1837                let d = binomial_neglog_q_fourth_derivative_dispatch(
1838                    y_p[row],
1839                    w_p[row],
1840                    q,
1841                    mu_p[row],
1842                    dmu_p[row],
1843                    d2mu_p[row],
1844                    d3mu_p[row],
1845                    link_kind_p,
1846                )?;
1847                let u = a + q * b;
1848                let u_i = (2.0 * b + q * c) * q_i;
1849                let u_j = (2.0 * b + q * c) * q_j;
1850                Ok(PsiSecondRow {
1851                    r_t: -a * r,
1852                    r_ls: -a * q,
1853                    dr_t_i: -b * q_i * r + a * r * z_ls_i[row],
1854                    dr_t_j: -b * q_j * r + a * r * z_ls_j[row],
1855                    dr_ls_i: -u * q_i,
1856                    dr_ls_j: -u * q_j,
1857                    d2r_t: r
1858                        * (-c * q_i * q_j - b * q_ij + b * (q_i * z_ls_j[row] + q_j * z_ls_i[row])
1859                            - a * z_ls_i[row] * z_ls_j[row]
1860                            + a * z_ls_ab[row]),
1861                    d2r_ls: -((2.0 * b + q * c) * q_i * q_j + u * q_ij),
1862                    h_tt: b * r * r,
1863                    h_tl: r * u,
1864                    h_ll: q * u,
1865                    dh_tt_i: r * r * (c * q_i - 2.0 * b * z_ls_i[row]),
1866                    dh_tt_j: r * r * (c * q_j - 2.0 * b * z_ls_j[row]),
1867                    dh_tl_i: r * (u_i - u * z_ls_i[row]),
1868                    dh_tl_j: r * (u_j - u * z_ls_j[row]),
1869                    dh_ll_i: (a + 3.0 * q * b + q * q * c) * q_i,
1870                    dh_ll_j: (a + 3.0 * q * b + q * q * c) * q_j,
1871                    d2h_tt: r
1872                        * r
1873                        * (d * q_i * q_j + c * q_ij
1874                            - 2.0 * c * (q_j * z_ls_i[row] + q_i * z_ls_j[row])
1875                            + 4.0 * b * z_ls_i[row] * z_ls_j[row]
1876                            - 2.0 * b * z_ls_ab[row]),
1877                    d2h_tl: r
1878                        * (((3.0 * c + q * d) * q_j) * q_i + (2.0 * b + q * c) * q_ij
1879                            - (2.0 * b + q * c) * (q_j * z_ls_i[row] + q_i * z_ls_j[row])
1880                            + u * (z_ls_i[row] * z_ls_j[row] - z_ls_ab[row])),
1881                    d2h_ll: (4.0 * b + 5.0 * q * c + q * q * d) * q_i * q_j
1882                        + (a + 3.0 * q * b + q * q * c) * q_ij,
1883                    objective: a * q_ij + b * q_i * q_j,
1884                })
1885            })
1886            .collect();
1887        for (row, vals) in rows?.into_iter().enumerate() {
1888            r_t[row] = vals.r_t;
1889            r_ls[row] = vals.r_ls;
1890            dr_t_i[row] = vals.dr_t_i;
1891            dr_t_j[row] = vals.dr_t_j;
1892            dr_ls_i[row] = vals.dr_ls_i;
1893            dr_ls_j[row] = vals.dr_ls_j;
1894            d2r_t[row] = vals.d2r_t;
1895            d2r_ls[row] = vals.d2r_ls;
1896            h_tt[row] = vals.h_tt;
1897            h_tl[row] = vals.h_tl;
1898            h_ll[row] = vals.h_ll;
1899            dh_tt_i[row] = vals.dh_tt_i;
1900            dh_tt_j[row] = vals.dh_tt_j;
1901            dh_tl_i[row] = vals.dh_tl_i;
1902            dh_tl_j[row] = vals.dh_tl_j;
1903            dh_ll_i[row] = vals.dh_ll_i;
1904            dh_ll_j[row] = vals.dh_ll_j;
1905            d2h_tt[row] = vals.d2h_tt;
1906            d2h_tl[row] = vals.d2h_tl;
1907            d2h_ll[row] = vals.d2h_ll;
1908            objective_psi_psi += vals.objective;
1909        }
1910        let mut score_psi_psi = Array1::<f64>::zeros(total);
1911        score_psi_psi.slice_mut(s![0..pt]).assign(
1912            &(x_t_ab_map.transpose_mul(r_t.view())
1913                + x_t_i_map.transpose_mul(dr_t_j.view())
1914                + x_t_j_map.transpose_mul(dr_t_i.view())
1915                + fast_atv(x_t, &d2r_t)),
1916        );
1917        score_psi_psi.slice_mut(s![pt..pt + pls]).assign(
1918            &(x_ls_ab_map.transpose_mul(r_ls.view())
1919                + x_ls_i_map.transpose_mul(dr_ls_j.view())
1920                + x_ls_j_map.transpose_mul(dr_ls_i.view())
1921                + fast_atv(x_ls, &d2r_ls)),
1922        );
1923
1924        let h_tt_block = weighted_crossprod_psi_maps(
1925            x_t_ab_map,
1926            h_tt.view(),
1927            CustomFamilyPsiLinearMapRef::Dense(x_t),
1928        )? + &weighted_crossprod_psi_maps(x_t_i_map, h_tt.view(), x_t_j_map)?
1929            + &weighted_crossprod_psi_maps(x_t_j_map, h_tt.view(), x_t_i_map)?
1930            + &weighted_crossprod_psi_maps(
1931                x_t_i_map,
1932                dh_tt_j.view(),
1933                CustomFamilyPsiLinearMapRef::Dense(x_t),
1934            )?
1935            + &weighted_crossprod_psi_maps(
1936                x_t_j_map,
1937                dh_tt_i.view(),
1938                CustomFamilyPsiLinearMapRef::Dense(x_t),
1939            )?
1940            + &weighted_crossprod_psi_maps(
1941                CustomFamilyPsiLinearMapRef::Dense(x_t),
1942                dh_tt_i.view(),
1943                x_t_j_map,
1944            )?
1945            + &weighted_crossprod_psi_maps(
1946                CustomFamilyPsiLinearMapRef::Dense(x_t),
1947                dh_tt_j.view(),
1948                x_t_i_map,
1949            )?
1950            + &xt_diag_x_dense(x_t, &d2h_tt)?
1951            + &weighted_crossprod_psi_maps(
1952                CustomFamilyPsiLinearMapRef::Dense(x_t),
1953                h_tt.view(),
1954                x_t_ab_map,
1955            )?;
1956        let h_tl_block = weighted_crossprod_psi_maps(
1957            x_t_ab_map,
1958            h_tl.view(),
1959            CustomFamilyPsiLinearMapRef::Dense(x_ls),
1960        )? + &weighted_crossprod_psi_maps(x_t_i_map, h_tl.view(), x_ls_j_map)?
1961            + &weighted_crossprod_psi_maps(x_t_j_map, h_tl.view(), x_ls_i_map)?
1962            + &weighted_crossprod_psi_maps(
1963                x_t_i_map,
1964                dh_tl_j.view(),
1965                CustomFamilyPsiLinearMapRef::Dense(x_ls),
1966            )?
1967            + &weighted_crossprod_psi_maps(
1968                x_t_j_map,
1969                dh_tl_i.view(),
1970                CustomFamilyPsiLinearMapRef::Dense(x_ls),
1971            )?
1972            + &weighted_crossprod_psi_maps(
1973                CustomFamilyPsiLinearMapRef::Dense(x_t),
1974                dh_tl_i.view(),
1975                x_ls_j_map,
1976            )?
1977            + &weighted_crossprod_psi_maps(
1978                CustomFamilyPsiLinearMapRef::Dense(x_t),
1979                dh_tl_j.view(),
1980                x_ls_i_map,
1981            )?
1982            + &xt_diag_y_dense(x_t, &d2h_tl, x_ls)?
1983            + &weighted_crossprod_psi_maps(
1984                CustomFamilyPsiLinearMapRef::Dense(x_t),
1985                h_tl.view(),
1986                x_ls_ab_map,
1987            )?;
1988        let h_ll_block = weighted_crossprod_psi_maps(
1989            x_ls_ab_map,
1990            h_ll.view(),
1991            CustomFamilyPsiLinearMapRef::Dense(x_ls),
1992        )? + &weighted_crossprod_psi_maps(x_ls_i_map, h_ll.view(), x_ls_j_map)?
1993            + &weighted_crossprod_psi_maps(x_ls_j_map, h_ll.view(), x_ls_i_map)?
1994            + &weighted_crossprod_psi_maps(
1995                x_ls_i_map,
1996                dh_ll_j.view(),
1997                CustomFamilyPsiLinearMapRef::Dense(x_ls),
1998            )?
1999            + &weighted_crossprod_psi_maps(
2000                x_ls_j_map,
2001                dh_ll_i.view(),
2002                CustomFamilyPsiLinearMapRef::Dense(x_ls),
2003            )?
2004            + &weighted_crossprod_psi_maps(
2005                CustomFamilyPsiLinearMapRef::Dense(x_ls),
2006                dh_ll_i.view(),
2007                x_ls_j_map,
2008            )?
2009            + &weighted_crossprod_psi_maps(
2010                CustomFamilyPsiLinearMapRef::Dense(x_ls),
2011                dh_ll_j.view(),
2012                x_ls_i_map,
2013            )?
2014            + &xt_diag_x_dense(x_ls, &d2h_ll)?
2015            + &weighted_crossprod_psi_maps(
2016                CustomFamilyPsiLinearMapRef::Dense(x_ls),
2017                h_ll.view(),
2018                x_ls_ab_map,
2019            )?;
2020
2021        let mut hessian_psi_psi = Array2::<f64>::zeros((total, total));
2022        hessian_psi_psi
2023            .slice_mut(s![0..pt, 0..pt])
2024            .assign(&h_tt_block);
2025        hessian_psi_psi
2026            .slice_mut(s![0..pt, pt..pt + pls])
2027            .assign(&h_tl_block);
2028        hessian_psi_psi
2029            .slice_mut(s![pt..pt + pls, pt..pt + pls])
2030            .assign(&h_ll_block);
2031        mirror_upper_to_lower(&mut hessian_psi_psi);
2032
2033        Ok(crate::custom_family::ExactNewtonJointPsiSecondOrderTerms {
2034            objective_psi_psi,
2035            score_psi_psi,
2036            hessian_psi_psi,
2037            hessian_psi_psi_operator: None,
2038        })
2039    }
2040
2041    pub(crate) fn exact_newton_joint_psihessian_directional_derivative_from_designs(
2042        &self,
2043        block_states: &[ParameterBlockState],
2044        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
2045        psi_index: usize,
2046        d_beta_flat: &Array1<f64>,
2047        x_t: &Array2<f64>,
2048        x_ls: &Array2<f64>,
2049    ) -> Result<Option<Array2<f64>>, String> {
2050        let Some(dir_a) = self.exact_newton_joint_psi_direction(
2051            block_states,
2052            derivative_blocks,
2053            psi_index,
2054            x_t,
2055            x_ls,
2056            &self.policy,
2057        )?
2058        else {
2059            return Ok(None);
2060        };
2061        Ok(Some(
2062            self.exact_newton_joint_psihessian_directional_derivative_from_parts(
2063                block_states,
2064                &dir_a,
2065                d_beta_flat,
2066                x_t,
2067                x_ls,
2068            )?,
2069        ))
2070    }
2071
2072    pub(crate) fn exact_newton_joint_psihessian_directional_derivative_from_parts(
2073        &self,
2074        block_states: &[ParameterBlockState],
2075        dir_a: &LocationScaleJointPsiDirection,
2076        d_beta_flat: &Array1<f64>,
2077        x_t: &Array2<f64>,
2078        x_ls: &Array2<f64>,
2079    ) -> Result<Array2<f64>, String> {
2080        let n = self.y.len();
2081        let eta_t = &block_states[Self::BLOCK_T].eta;
2082        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
2083        let core = binomial_location_scale_core(
2084            &self.y,
2085            &self.weights,
2086            eta_t,
2087            eta_ls,
2088            None,
2089            &self.link_kind,
2090        )?;
2091        let pt = x_t.ncols();
2092        let pls = x_ls.ncols();
2093        let total = pt + pls;
2094        if d_beta_flat.len() != total {
2095            return Err(GamlssError::DimensionMismatch { reason: format!(
2096                "BinomialLocationScaleFamily joint psi hessian directional derivative length mismatch: got {}, expected {}",
2097                d_beta_flat.len(),
2098                total
2099            ) }.into());
2100        }
2101        let xi_t = fast_av(x_t, &d_beta_flat.slice(s![0..pt]));
2102        let xi_ls = fast_av(x_ls, &d_beta_flat.slice(s![pt..pt + pls]));
2103        let x_t_map = dir_a.x_primary_psi.as_linear_map_ref();
2104        let x_ls_map = dir_a.x_ls_psi.as_linear_map_ref();
2105
2106        // Mixed contraction T_a[u] = D_beta H_{psi_a}[u].
2107        //
2108        // In the non-wiggle family the realized design derivatives X_{psi_a}
2109        // are fixed with respect to beta, so differentiating the explicit
2110        // Hessian drift H_{psi_a} only moves the rowwise coefficient arrays.
2111        // This helper therefore returns exactly the likelihood-side mixed drift
2112        // required by the unified outer Hessian formula
2113        //
2114        //   ddot H_{ij}
2115        //   = H_{ij}
2116        //     + T_i[beta_j]
2117        //     + T_j[beta_i]
2118        //     + D_beta H[beta_ij]
2119        //     + D_beta^2 H[beta_i, beta_j].
2120        //
2121        // For i = psi_a, the generic assembler supplies beta_j and any
2122        // realized-penalty piece S_{psi_a} itself; this family hook contributes
2123        // only the exact likelihood-side T_a[beta_j].
2124        //
2125        // With
2126        //   du   = D_beta q[u]   = -r xi_t - q xi_ls,
2127        //   q_a  = q_{psi_a}     = -r z_t,a - q z_ls,a,
2128        //   q_au = D_beta q_a[u] = r z_t,a xi_ls - du z_ls,a,
2129        //
2130        // the directional derivatives of the first-order Hessian-drift
2131        // coefficients are the mixed specializations of the exact psi/psi
2132        // formulas with z_ls,ab = 0 and q_ab = q_au:
2133        //
2134        //   D_u(d_a h_tt)
2135        //   = r²[
2136        //       d du q_a + c q_au
2137        //       - 2c(q_a xi_ls + du z_ls,a)
2138        //       + 4b xi_ls z_ls,a
2139        //     ],
2140        //
2141        //   D_u(d_a h_tl)
2142        //   = r[
2143        //       ((3c + q d) q_a) du
2144        //       + (2b + q c) q_au
2145        //       - (2b + q c)(q_a xi_ls + du z_ls,a)
2146        //       + (a + q b) xi_ls z_ls,a
2147        //     ],
2148        //
2149        //   D_u(d_a h_ll)
2150        //   = (4b + 5q c + q² d) du q_a
2151        //     + (a + 3q b + q² c) q_au.
2152        //
2153        // Since X_t, X_ls, X_{t,psi_a}, X_{ls,psi_a} are all beta-independent
2154        // here, the full matrix contraction is obtained by replacing the row
2155        // coefficient arrays in H_{psi_a} by their directional derivatives.
2156        let mut dh_tt_u = Array1::<f64>::zeros(n);
2157        let mut dh_tl_u = Array1::<f64>::zeros(n);
2158        let mut dh_ll_u = Array1::<f64>::zeros(n);
2159        let mut h_tt_u = Array1::<f64>::zeros(n);
2160        let mut h_tl_u = Array1::<f64>::zeros(n);
2161        let mut h_ll_u = Array1::<f64>::zeros(n);
2162        for row in 0..n {
2163            let q = core.q0[row];
2164            let r = 1.0 / core.sigma[row];
2165            let s = core.dsigma_deta[row] / core.sigma[row];
2166            let xi_ls_s = s * xi_ls[row];
2167            let z_ls_psi_s = s * dir_a.z_ls_psi[row];
2168            let du = -r * xi_t[row] - q * xi_ls_s;
2169            let q_a = -r * dir_a.z_primary_psi[row] - q * z_ls_psi_s;
2170            let q_au = r * dir_a.z_primary_psi[row] * xi_ls_s - du * z_ls_psi_s;
2171            let (a, b, c) = binomial_neglog_q_derivatives_dispatch(
2172                self.y[row],
2173                self.weights[row],
2174                q,
2175                core.mu[row],
2176                core.dmu_dq[row],
2177                core.d2mu_dq2[row],
2178                core.d3mu_dq3[row],
2179                &self.link_kind,
2180            );
2181            let d = binomial_neglog_q_fourth_derivative_dispatch(
2182                self.y[row],
2183                self.weights[row],
2184                q,
2185                core.mu[row],
2186                core.dmu_dq[row],
2187                core.d2mu_dq2[row],
2188                core.d3mu_dq3[row],
2189                &self.link_kind,
2190            )?;
2191            let u = a + q * b;
2192            h_tt_u[row] = r * r * (c * du - 2.0 * b * xi_ls_s);
2193            h_tl_u[row] = r * ((2.0 * b + q * c) * du - u * xi_ls_s);
2194            h_ll_u[row] = (a + 3.0 * q * b + q * q * c) * du;
2195            dh_tt_u[row] = r
2196                * r
2197                * (d * du * q_a + c * q_au - 2.0 * c * (q_a * xi_ls_s + du * z_ls_psi_s)
2198                    + 4.0 * b * xi_ls_s * z_ls_psi_s);
2199            dh_tl_u[row] = r
2200                * (((3.0 * c + q * d) * q_a) * du + (2.0 * b + q * c) * q_au
2201                    - (2.0 * b + q * c) * (q_a * xi_ls_s + du * z_ls_psi_s)
2202                    + u * xi_ls_s * z_ls_psi_s);
2203            dh_ll_u[row] = (4.0 * b + 5.0 * q * c + q * q * d) * du * q_a
2204                + (a + 3.0 * q * b + q * q * c) * q_au;
2205        }
2206
2207        let tt_block = weighted_crossprod_psi_maps(
2208            x_t_map,
2209            h_tt_u.view(),
2210            CustomFamilyPsiLinearMapRef::Dense(x_t),
2211        )? + &weighted_crossprod_psi_maps(
2212            CustomFamilyPsiLinearMapRef::Dense(x_t),
2213            h_tt_u.view(),
2214            x_t_map,
2215        )? + &xt_diag_x_dense(x_t, &dh_tt_u)?;
2216        let tl_block = weighted_crossprod_psi_maps(
2217            x_t_map,
2218            h_tl_u.view(),
2219            CustomFamilyPsiLinearMapRef::Dense(x_ls),
2220        )? + &weighted_crossprod_psi_maps(
2221            CustomFamilyPsiLinearMapRef::Dense(x_t),
2222            h_tl_u.view(),
2223            x_ls_map,
2224        )? + &xt_diag_y_dense(x_t, &dh_tl_u, x_ls)?;
2225        let ll_block = weighted_crossprod_psi_maps(
2226            x_ls_map,
2227            h_ll_u.view(),
2228            CustomFamilyPsiLinearMapRef::Dense(x_ls),
2229        )? + &weighted_crossprod_psi_maps(
2230            CustomFamilyPsiLinearMapRef::Dense(x_ls),
2231            h_ll_u.view(),
2232            x_ls_map,
2233        )? + &xt_diag_x_dense(x_ls, &dh_ll_u)?;
2234        let mut out = Array2::<f64>::zeros((total, total));
2235        out.slice_mut(s![0..pt, 0..pt]).assign(&tt_block);
2236        out.slice_mut(s![0..pt, pt..pt + pls]).assign(&tl_block);
2237        out.slice_mut(s![pt..pt + pls, pt..pt + pls])
2238            .assign(&ll_block);
2239        mirror_upper_to_lower(&mut out);
2240        Ok(out)
2241    }
2242
2243    /// Build the [`BlockEffectiveJacobian`] for block `block_idx`.
2244    ///
2245    /// The two-output map is (η_threshold, η_log_sigma):
2246    /// - block 0 (threshold):  output 0 = design rows, output 1 = zeros
2247    /// - block 1 (log_sigma):  output 0 = zeros, output 1 = design rows
2248    pub fn block_effective_jacobian(
2249        specs: &[ParameterBlockSpec],
2250        block_idx: usize,
2251    ) -> Result<Box<dyn BlockEffectiveJacobian>, String> {
2252        crate::block_layout::block_jacobian::AdditiveWiggleBlockLayout {
2253            family: "BinomialLocationScaleFamily",
2254            n_outputs: 2,
2255            additive_blocks: &[Self::BLOCK_T, Self::BLOCK_LOG_SIGMA],
2256            wiggle_block: None,
2257        }
2258        .block_effective_jacobian(specs, block_idx)
2259    }
2260}
2261
2262impl CustomFamily for BinomialLocationScaleFamily {
2263    // Binomial fits have a genuine separation regime; keep the self-limiting
2264    // Jeffreys/Firth curvature active. The trait default flipped to OFF in
2265    // gam#1395 (flat-prior exact-Newton objective); opt back in here.
2266    fn joint_jeffreys_term_required(&self) -> bool {
2267        true
2268    }
2269
2270    /// The Binomial location-scale joint Hessian depends on β because the
2271    /// Hessian blocks are functions of q = -t/σ and the link derivatives,
2272    /// all of which change when β_t or β_{log σ} move.
2273    fn exact_newton_joint_hessian_beta_dependent(&self) -> bool {
2274        true
2275    }
2276
2277    fn coefficient_hessian_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
2278        // Operator-aware: matrix-free workspace applies joint Hv at
2279        // O(n · (p_t + p_ℓ)); only fall back to the dense build cost when
2280        // `use_joint_matrix_free_path` declines the operator path.
2281        crate::location_scale_engine::location_scale_coefficient_hessian_cost(
2282            self.y.len() as u64,
2283            specs,
2284        )
2285    }
2286
2287    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
2288        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
2289        let n = self.y.len();
2290        let eta_t = &block_states[Self::BLOCK_T].eta;
2291        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
2292        if eta_t.len() != n || eta_ls.len() != n || self.weights.len() != n {
2293            return Err(GamlssError::DimensionMismatch {
2294                reason: "BinomialLocationScaleFamily input size mismatch".to_string(),
2295            }
2296            .into());
2297        }
2298
2299        let core = binomial_location_scale_core(
2300            &self.y,
2301            &self.weights,
2302            eta_t,
2303            eta_ls,
2304            None,
2305            &self.link_kind,
2306        )?;
2307        if !self.exact_joint_supported() {
2308            return Err(
2309                "BinomialLocationScaleFamily requires exact curvature designs; diagonal fallback has been removed"
2310                    .to_string(),
2311            );
2312        }
2313        let threshold_design = self.threshold_design.as_ref().ok_or_else(|| {
2314            "BinomialLocationScaleFamily exact path is missing threshold design".to_string()
2315        })?;
2316        let log_sigma_design = self.log_sigma_design.as_ref().ok_or_else(|| {
2317            "BinomialLocationScaleFamily exact path is missing log-sigma design".to_string()
2318        })?;
2319
2320        // Per-block gradients from the eta-space score.
2321        //
2322        //   score_q = -m1   (m1 = dF/dq, F = -ℓ)
2323        //   grad_eta_t[i]  = score_q * q_t
2324        //   grad_eta_ls[i] = score_q * q_ls
2325        let mut grad_eta_t_v = vec![0.0_f64; n];
2326        let mut grad_eta_ls_v = vec![0.0_f64; n];
2327        let y_slice_e = self.y.as_slice().expect("y must be contiguous");
2328        let w_slice_e = self.weights.as_slice().expect("weights must be contiguous");
2329        let q0_slice_e = core.q0.as_slice().expect("q0 must be contiguous");
2330        let eta_t_slice_e = eta_t.as_slice().expect("eta_t must be contiguous");
2331        let eta_ls_slice_e = eta_ls.as_slice().expect("eta_ls must be contiguous");
2332        let link_kind_e = &self.link_kind;
2333        let gradient_pairs: Result<Vec<(f64, f64)>, String> = (0..n)
2334            .into_par_iter()
2335            .map(|i| {
2336                let tower = binomial_location_scale_nll_tower(
2337                    y_slice_e[i],
2338                    w_slice_e[i],
2339                    eta_t_slice_e[i],
2340                    eta_ls_slice_e[i],
2341                    q0_slice_e[i],
2342                    core.mu[i],
2343                    core.dmu_dq[i],
2344                    core.d2mu_dq2[i],
2345                    core.d3mu_dq3[i],
2346                    link_kind_e,
2347                    false,
2348                )?;
2349                Ok((-tower.g[0], -tower.g[1]))
2350            })
2351            .collect();
2352        for (i, (g_t, g_ls)) in gradient_pairs?.into_iter().enumerate() {
2353            grad_eta_t_v[i] = g_t;
2354            grad_eta_ls_v[i] = g_ls;
2355        }
2356        let grad_eta_t = Array1::from_vec(grad_eta_t_v);
2357        let grad_eta_ls = Array1::from_vec(grad_eta_ls_v);
2358        let grad_t = threshold_design.transpose_vector_multiply(&grad_eta_t);
2359        let grad_ls = log_sigma_design.transpose_vector_multiply(&grad_eta_ls);
2360
2361        // Per-block Hessians without ever materializing the full p×p joint
2362        // matrix — the off-diagonal cross block is unused for IRLS-style block
2363        // working sets and would cost O(p_t * p_ls * n) to form. The diagonal
2364        // blocks are computed from the same row coefficients as the joint.
2365        let (h_tt, h_ll) = self.exact_newton_block_diagonal_hessians_from_design_matrices(
2366            block_states,
2367            threshold_design,
2368            log_sigma_design,
2369        )?;
2370        Ok(FamilyEvaluation {
2371            log_likelihood: core.log_likelihood,
2372            blockworking_sets: vec![
2373                BlockWorkingSet::ExactNewton {
2374                    gradient: grad_t,
2375                    hessian: SymmetricMatrix::Dense(h_tt),
2376                },
2377                BlockWorkingSet::ExactNewton {
2378                    gradient: grad_ls,
2379                    hessian: SymmetricMatrix::Dense(h_ll),
2380                },
2381            ],
2382        })
2383    }
2384
2385    fn log_likelihood_only(&self, block_states: &[ParameterBlockState]) -> Result<f64, String> {
2386        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
2387        let n = self.y.len();
2388        let eta_t = &block_states[Self::BLOCK_T].eta;
2389        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
2390        if eta_t.len() != n || eta_ls.len() != n || self.weights.len() != n {
2391            return Err(GamlssError::DimensionMismatch {
2392                reason: "BinomialLocationScaleFamily input size mismatch".to_string(),
2393            }
2394            .into());
2395        }
2396        // Zero-allocation O(n) scalar loop — no working sets, no n-vector intermediates.
2397        binomial_location_scale_ll_only(
2398            &self.y,
2399            &self.weights,
2400            eta_t,
2401            eta_ls,
2402            None,
2403            &self.link_kind,
2404        )
2405    }
2406
2407    /// Outer-only log-likelihood with optional row subsample.
2408    ///
2409    /// When `options.outer_score_subsample` is `Some`, only the sampled rows
2410    /// contribute; each row's per-row log-likelihood term is multiplied by
2411    /// `WeightedOuterRow.weight`, the Horvitz–Thompson inverse-inclusion
2412    /// factor 1/π_i (uniform or stratified sampling both supported), so the
2413    /// partial sum is an unbiased estimator of the full-data log-likelihood.
2414    /// When `None`, this returns the full-data `log_likelihood_only`. Inner
2415    /// PIRLS line searches never install the subsample option, so they
2416    /// continue to score the exact full-data log-likelihood.
2417    fn log_likelihood_only_with_options(
2418        &self,
2419        block_states: &[ParameterBlockState],
2420        options: &BlockwiseFitOptions,
2421    ) -> Result<f64, String> {
2422        let Some(subsample) = options.outer_score_subsample.as_ref() else {
2423            return self.log_likelihood_only(block_states);
2424        };
2425        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
2426        let n = self.y.len();
2427        let eta_t = &block_states[Self::BLOCK_T].eta;
2428        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
2429        if eta_t.len() != n || eta_ls.len() != n || self.weights.len() != n {
2430            return Err(GamlssError::DimensionMismatch {
2431                reason: "BinomialLocationScaleFamily input size mismatch".to_string(),
2432            }
2433            .into());
2434        }
2435        use rayon::iter::ParallelIterator;
2436        let link_kind = &self.link_kind;
2437        let ll: Result<f64, String> = subsample
2438            .rows
2439            .par_iter()
2440            .try_fold(
2441                || 0.0_f64,
2442                |acc, row| -> Result<f64, String> {
2443                    let i = row.index;
2444                    let wi = self.weights[i];
2445                    if wi == 0.0 {
2446                        return Ok(acc);
2447                    }
2448                    let SigmaJet1 { sigma, .. } = exp_sigma_jet1_scalar(eta_ls[i]);
2449                    let q = binomial_location_scale_q0(eta_t[i], sigma);
2450                    let mu = if matches!(link_kind, InverseLink::Standard(StandardLink::Probit)) {
2451                        0.5
2452                    } else {
2453                        let jet = inverse_link_jet_for_inverse_link(link_kind, q).map_err(|e| {
2454                            format!("location-scale inverse-link evaluation failed: {e}")
2455                        })?;
2456                        jet.mu
2457                    };
2458                    let term =
2459                        binomial_location_scale_log_likelihood(self.y[i], wi, q, link_kind, mu)?;
2460                    Ok(acc + row.weight * term)
2461                },
2462            )
2463            .try_reduce(|| 0.0_f64, |a, b| Ok(a + b));
2464        ll
2465    }
2466
2467    fn requires_joint_outer_hyper_path(&self) -> bool {
2468        true
2469    }
2470
2471    fn diagonalworking_weights_directional_derivative(
2472        &self,
2473        _: &[ParameterBlockState],
2474        _: usize,
2475        arr: &Array1<f64>,
2476    ) -> Result<Option<Array1<f64>>, String> {
2477        // Default implementation ignores this parameter.
2478        assert!(arr.iter().all(|v| !v.is_nan()));
2479        Err(
2480            "BinomialLocationScaleFamily no longer supports diagonal working weights; exact curvature is required"
2481                .to_string(),
2482        )
2483    }
2484
2485    fn exact_newton_joint_psi_terms(
2486        &self,
2487        block_states: &[ParameterBlockState],
2488        specs: &[ParameterBlockSpec],
2489        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
2490        psi_index: usize,
2491    ) -> Result<Option<crate::custom_family::ExactNewtonJointPsiTerms>, String> {
2492        self.exact_newton_joint_psi_terms_for_specs(
2493            block_states,
2494            specs,
2495            derivative_blocks,
2496            psi_index,
2497        )
2498    }
2499
2500    fn exact_newton_joint_psisecond_order_terms(
2501        &self,
2502        block_states: &[ParameterBlockState],
2503        specs: &[ParameterBlockSpec],
2504        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
2505        psi_i: usize,
2506        psi_j: usize,
2507    ) -> Result<Option<crate::custom_family::ExactNewtonJointPsiSecondOrderTerms>, String> {
2508        self.exact_newton_joint_psisecond_order_terms_for_specs(
2509            block_states,
2510            specs,
2511            derivative_blocks,
2512            psi_i,
2513            psi_j,
2514        )
2515    }
2516
2517    fn exact_newton_joint_psihessian_directional_derivative(
2518        &self,
2519        block_states: &[ParameterBlockState],
2520        specs: &[ParameterBlockSpec],
2521        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
2522        psi_index: usize,
2523        d_beta_flat: &Array1<f64>,
2524    ) -> Result<Option<Array2<f64>>, String> {
2525        self.exact_newton_joint_psihessian_directional_derivative_for_specs(
2526            block_states,
2527            specs,
2528            derivative_blocks,
2529            psi_index,
2530            d_beta_flat,
2531        )
2532    }
2533
2534    fn exact_newton_joint_psi_workspace(
2535        &self,
2536        block_states: &[ParameterBlockState],
2537        specs: &[ParameterBlockSpec],
2538        derivative_blocks: &[Vec<crate::custom_family::CustomFamilyBlockPsiDerivative>],
2539    ) -> Result<Option<Arc<dyn ExactNewtonJointPsiWorkspace>>, String> {
2540        if !self.exact_joint_supported() {
2541            return Ok(None);
2542        }
2543        Ok(Some(Arc::new(
2544            BinomialLocationScaleExactNewtonJointPsiWorkspace::new(
2545                self.clone(),
2546                block_states.to_vec(),
2547                specs,
2548                derivative_blocks.to_vec(),
2549            )?,
2550        )))
2551    }
2552
2553    fn exact_newton_hessian_directional_derivative(
2554        &self,
2555        block_states: &[ParameterBlockState],
2556        block_idx: usize,
2557        d_beta: &Array1<f64>,
2558    ) -> Result<Option<Array2<f64>>, String> {
2559        if !self.exact_joint_supported() {
2560            return Ok(None);
2561        }
2562        let pt = self
2563            .threshold_design
2564            .as_ref()
2565            .ok_or_else(|| {
2566                "BinomialLocationScaleFamily exact path is missing threshold design".to_string()
2567            })?
2568            .ncols();
2569        let pls = self
2570            .log_sigma_design
2571            .as_ref()
2572            .ok_or_else(|| {
2573                "BinomialLocationScaleFamily exact path is missing log-sigma design".to_string()
2574            })?
2575            .ncols();
2576        let total = pt + pls;
2577        let (start, end, joint_direction) = match block_idx {
2578            Self::BLOCK_T => {
2579                if d_beta.len() != pt {
2580                    return Err(GamlssError::DimensionMismatch { reason: format!(
2581                        "BinomialLocationScaleFamily threshold d_beta length mismatch: got {}, expected {}",
2582                        d_beta.len(),
2583                        pt
2584                    ) }.into());
2585                }
2586                let mut dir = Array1::<f64>::zeros(total);
2587                dir.slice_mut(s![0..pt]).assign(d_beta);
2588                (0usize, pt, dir)
2589            }
2590            Self::BLOCK_LOG_SIGMA => {
2591                if d_beta.len() != pls {
2592                    return Err(GamlssError::DimensionMismatch { reason: format!(
2593                        "BinomialLocationScaleFamily log-sigma d_beta length mismatch: got {}, expected {}",
2594                        d_beta.len(),
2595                        pls
2596                    ) }.into());
2597                }
2598                let mut dir = Array1::<f64>::zeros(total);
2599                dir.slice_mut(s![pt..pt + pls]).assign(d_beta);
2600                (pt, pt + pls, dir)
2601            }
2602            _ => return Ok(None),
2603        };
2604        let joint = self
2605            .exact_newton_joint_hessian_directional_derivative(block_states, &joint_direction)?
2606            .ok_or_else(|| {
2607                format!("missing joint exact-newton directional Hessian for block {block_idx}")
2608            })?;
2609        Ok(Some(joint.slice(s![start..end, start..end]).to_owned()))
2610    }
2611
2612    fn exact_newton_joint_hessian(
2613        &self,
2614        block_states: &[ParameterBlockState],
2615    ) -> Result<Option<Array2<f64>>, String> {
2616        self.exact_newton_joint_hessian_for_specs(block_states, None)
2617    }
2618
2619    fn has_explicit_joint_hessian(&self) -> bool {
2620        true
2621    }
2622
2623    fn exact_newton_joint_hessian_directional_derivative(
2624        &self,
2625        block_states: &[ParameterBlockState],
2626        d_beta_flat: &Array1<f64>,
2627    ) -> Result<Option<Array2<f64>>, String> {
2628        self.exact_newton_joint_hessian_directional_derivative_for_specs(
2629            block_states,
2630            None,
2631            d_beta_flat,
2632        )
2633    }
2634
2635    fn exact_newton_joint_hessiansecond_directional_derivative(
2636        &self,
2637        block_states: &[ParameterBlockState],
2638        d_beta_u_flat: &Array1<f64>,
2639        d_betav_flat: &Array1<f64>,
2640    ) -> Result<Option<Array2<f64>>, String> {
2641        self.exact_newton_joint_hessian_second_directional_derivative_for_specs(
2642            block_states,
2643            None,
2644            d_beta_u_flat,
2645            d_betav_flat,
2646        )
2647    }
2648
2649    fn exact_newton_joint_hessian_with_specs(
2650        &self,
2651        block_states: &[ParameterBlockState],
2652        specs: &[ParameterBlockSpec],
2653    ) -> Result<Option<Array2<f64>>, String> {
2654        self.exact_newton_joint_hessian_for_specs(block_states, Some(specs))
2655    }
2656
2657    fn exact_newton_joint_hessian_directional_derivative_with_specs(
2658        &self,
2659        block_states: &[ParameterBlockState],
2660        specs: &[ParameterBlockSpec],
2661        d_beta_flat: &Array1<f64>,
2662    ) -> Result<Option<Array2<f64>>, String> {
2663        self.exact_newton_joint_hessian_directional_derivative_for_specs(
2664            block_states,
2665            Some(specs),
2666            d_beta_flat,
2667        )
2668    }
2669
2670    fn exact_newton_joint_hessian_second_directional_derivative_with_specs(
2671        &self,
2672        block_states: &[ParameterBlockState],
2673        specs: &[ParameterBlockSpec],
2674        d_beta_u_flat: &Array1<f64>,
2675        d_betav_flat: &Array1<f64>,
2676    ) -> Result<Option<Array2<f64>>, String> {
2677        self.exact_newton_joint_hessian_second_directional_derivative_for_specs(
2678            block_states,
2679            Some(specs),
2680            d_beta_u_flat,
2681            d_betav_flat,
2682        )
2683    }
2684
2685    fn joint_jeffreys_information_with_specs(
2686        &self,
2687        block_states: &[ParameterBlockState],
2688        specs: &[ParameterBlockSpec],
2689    ) -> Result<Option<Array2<f64>>, String> {
2690        self.expected_joint_information_for_specs(block_states, Some(specs))
2691    }
2692
2693    fn joint_jeffreys_information_directional_derivative_with_specs(
2694        &self,
2695        block_states: &[ParameterBlockState],
2696        specs: &[ParameterBlockSpec],
2697        d_beta_flat: &Array1<f64>,
2698    ) -> Result<Option<Array2<f64>>, String> {
2699        self.expected_joint_information_directional_for_specs(
2700            block_states,
2701            Some(specs),
2702            d_beta_flat,
2703        )
2704    }
2705
2706    fn joint_jeffreys_information_second_directional_derivative_with_specs(
2707        &self,
2708        block_states: &[ParameterBlockState],
2709        specs: &[ParameterBlockSpec],
2710        d_beta_u_flat: &Array1<f64>,
2711        d_betav_flat: &Array1<f64>,
2712    ) -> Result<Option<Array2<f64>>, String> {
2713        self.expected_joint_information_second_directional_for_specs(
2714            block_states,
2715            Some(specs),
2716            d_beta_u_flat,
2717            d_betav_flat,
2718        )
2719    }
2720
2721    fn joint_jeffreys_information_contracted_trace_hessian_with_specs(
2722        &self,
2723        block_states: &[ParameterBlockState],
2724        specs: &[ParameterBlockSpec],
2725        weight: &Array2<f64>,
2726    ) -> Result<Option<Array2<f64>>, String> {
2727        self.expected_joint_contracted_trace_hessian_for_specs(block_states, Some(specs), weight)
2728    }
2729
2730    fn joint_jeffreys_information_contracted_trace_hessian_available(&self) -> bool {
2731        true
2732    }
2733
2734    fn joint_jeffreys_information_matches_observed_hessian(&self) -> bool {
2735        // The Jeffreys information above is the EXPECTED Fisher information,
2736        // not the observed Hessian: observed-Hessian conditioning certificates
2737        // ("Jeffreys provably skippable" matvec pre-checks) must not gate the
2738        // expected-information term off — for probit-class likelihoods the
2739        // observed information grows on saturated misclassified rows exactly
2740        // where the expected information collapses and the gate must arm
2741        // (gam#1020).
2742        false
2743    }
2744
2745    fn exact_newton_joint_gradient_evaluation(
2746        &self,
2747        block_states: &[ParameterBlockState],
2748        specs: &[ParameterBlockSpec],
2749    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
2750        let Some((x_t, x_ls)) = self.exact_joint_block_designs_owned(Some(specs))? else {
2751            return Ok(None);
2752        };
2753        self.exact_newton_joint_gradient_from_designs(block_states, &x_t, &x_ls)
2754            .map(Some)
2755    }
2756
2757    fn exact_newton_joint_hessian_workspace(
2758        &self,
2759        block_states: &[ParameterBlockState],
2760        specs: &[ParameterBlockSpec],
2761    ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
2762        let Some((x_t, x_ls)) = self.exact_joint_block_designs_owned(Some(specs))? else {
2763            return Ok(None);
2764        };
2765        let workspace = BinomialLocationScaleHessianWorkspace::new(
2766            self.clone(),
2767            block_states.to_vec(),
2768            x_t,
2769            x_ls,
2770        )?;
2771        Ok(Some(Arc::new(workspace)))
2772    }
2773
2774    /// Outer-aware joint-Hessian workspace with optional row subsample.
2775    ///
2776    /// When `options.outer_score_subsample` is `None`, this is byte-identical
2777    /// to `exact_newton_joint_hessian_workspace`. When `Some`, the precomputed
2778    /// per-row coefficient arrays (`coeff_tt`, `coeff_tl`, `coeff_ll`) — which
2779    /// every downstream assembly (`hessian_dense`, `hessian_matvec`,
2780    /// `hessian_diagonal`) consumes row-linearly via `Xᵀ diag(W) X` — are
2781    /// replaced by a Horvitz–Thompson mask: each sampled row's coefficient is
2782    /// multiplied by `WeightedOuterRow.weight` (the inverse-inclusion factor
2783    /// 1/π_i; uniform or stratified sampling both supported), and non-sampled
2784    /// rows are zeroed. The resulting joint Hessian is an unbiased estimator
2785    /// of the full-data joint Hessian. Inner PIRLS never installs the option,
2786    /// so the inner solve continues to consume the exact full-data Hessian.
2787    fn exact_newton_joint_hessian_workspace_with_options(
2788        &self,
2789        block_states: &[ParameterBlockState],
2790        specs: &[ParameterBlockSpec],
2791        options: &BlockwiseFitOptions,
2792    ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
2793        let Some((x_t, x_ls)) = self.exact_joint_block_designs_owned(Some(specs))? else {
2794            return Ok(None);
2795        };
2796        let mut workspace = BinomialLocationScaleHessianWorkspace::new(
2797            self.clone(),
2798            block_states.to_vec(),
2799            x_t,
2800            x_ls,
2801        )?;
2802        if let Some(subsample) = options.outer_score_subsample.as_ref() {
2803            workspace.apply_outer_subsample(subsample.rows.as_ref());
2804        }
2805        Ok(Some(Arc::new(workspace)))
2806    }
2807
2808    /// Outer-derivative policy: declare HT-subsample capability.
2809    ///
2810    /// BinomialLocationScaleFamily overrides
2811    /// `log_likelihood_only_with_options` and
2812    /// `exact_newton_joint_hessian_workspace_with_options` to consume
2813    /// `options.outer_score_subsample` with per-row Horvitz–Thompson weights
2814    /// (each sampled row's contribution is multiplied by
2815    /// `WeightedOuterRow.weight = 1/π_i`; non-sampled rows are zeroed),
2816    /// yielding unbiased estimators of the full-data log-likelihood and
2817    /// joint Hessian. The ψ-workspace path is not yet subsample-aware: it
2818    /// builds the exact full-data ψ Hessian blocks, which are trivially
2819    /// unbiased; so the outer-score components are a sum of HT-unbiased and
2820    /// exact-unbiased pieces and the total remains an unbiased estimator of
2821    /// the full-data outer score. Inner-PIRLS and final-covariance paths
2822    /// never install the option, so they continue to consume the exact
2823    /// full-data quantities.
2824    fn outer_derivative_subsample_capable(&self) -> bool {
2825        true
2826    }
2827
2828    fn inner_coefficient_hessian_hvp_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2829        // Representation support means the realized two-block designs can be
2830        // applied as β-space operators. It does not imply that exact
2831        // second-order outer θ work is cheap.
2832        if specs.len() != 2 {
2833            return false;
2834        }
2835        let n = self.y.len();
2836        specs[Self::BLOCK_T].design.nrows() == n && specs[Self::BLOCK_LOG_SIGMA].design.nrows() == n
2837    }
2838}
2839
2840impl CustomFamilyGenerative for BinomialLocationScaleFamily {
2841    fn generativespec(
2842        &self,
2843        block_states: &[ParameterBlockState],
2844    ) -> Result<GenerativeSpec, String> {
2845        validate_block_count::<GamlssError>("BinomialLocationScaleFamily", 2, block_states.len())?;
2846        let eta_t = &block_states[Self::BLOCK_T].eta;
2847        let eta_ls = &block_states[Self::BLOCK_LOG_SIGMA].eta;
2848        if eta_t.len() != self.y.len() || eta_ls.len() != self.y.len() {
2849            return Err(GamlssError::DimensionMismatch {
2850                reason: "BinomialLocationScaleFamily generative size mismatch".to_string(),
2851            }
2852            .into());
2853        }
2854        let mean = gamlss_rowwise_map_result(self.y.len(), |i| {
2855            let sigma = exp_sigma_from_eta_scalar(eta_ls[i]);
2856            let q = binomial_location_scale_q0(eta_t[i], sigma);
2857            let jet = inverse_link_jet_for_inverse_link(&self.link_kind, q)
2858                .map_err(|e| format!("location-scale inverse-link evaluation failed: {e}"))?;
2859            Ok(jet.mu)
2860        })?;
2861        Ok(GenerativeSpec {
2862            mean,
2863            noise: NoiseModel::Bernoulli,
2864        })
2865    }
2866}