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