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