Skip to main content

gam_models/gamlss/gaussian/
binomial_mean_wiggle.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
7#[derive(Clone)]
8pub struct BinomialMeanWiggleFamily {
9    pub y: Array1<f64>,
10    pub weights: Array1<f64>,
11    pub link_kind: InverseLink,
12    pub wiggle_knots: Array1<f64>,
13    pub wiggle_degree: usize,
14    /// Resource policy threaded into PsiDesignMap construction during
15    /// exact-Newton joint psi evaluation. Defaults to
16    /// `ResourcePolicy::default_library()` when the family is built without
17    /// an explicit policy.
18    pub policy: gam_runtime::resource::ResourcePolicy,
19    /// The **frozen, identifiable** warp design
20    /// `B⊥ = (I - P_X) B(η̂)` used for the duration of one inner joint-Newton
21    /// solve (#1596). `None` preserves the original dynamic-basis behaviour;
22    /// `Some(B⊥)` switches the family into the
23    /// frozen-basis Gauss-Newton mode.
24    ///
25    /// **Why frozen.** The fully-coupled `q = η + B(η)·β_w` model regenerates the
26    /// monotone I-spline basis at the *moving* `η` every cycle. The trust-region
27    /// quadratic model freezes `B` at the cycle-start `η`, but the line search
28    /// re-evaluates the objective with `B` rebuilt at the trial `η`; for any step
29    /// that moves `η` the actual reduction diverges from the model, the trust
30    /// radius collapses, and the constrained KKT certificate refuses every
31    /// iterate (`active_set_incomplete`) even when the optimal warp is flat.
32    /// Freezing `B(η̂)` makes `q = η + B⊥·β_w` linear in `(β_η, β_w)` with
33    /// `∂q/∂η = 1` and no `∂B/∂η` chain term — a well-conditioned two-block GLM
34    /// that certifies. The caller re-freezes at the refit `η̂` and returns only
35    /// after the caller-supplied outer convergence policy certifies the resulting
36    /// Gauss-Newton fixed point (`fit_binomial_mean_wiggle`).
37    ///
38    /// **Why observation-space residualized (identifiable).** A monotone
39    /// I-spline of the linear predictor `η` can represent mean-block directions,
40    /// so the raw `B(η̂)` columns alias `X`. The caller fits
41    /// `B⊥ = B - X A`, with `A = (XᵀX)^+XᵀB`, removing only that aliased
42    /// observation-space component while preserving the standard I-spline
43    /// coefficient coordinate. Consequently the exact structural constraint is
44    /// still simply `β_w ≥ 0`; prediction reconstructs `B(η_new)·β_w` and
45    /// compensates the saved mean coefficient by `-Aβ_w`.
46    pub frozen_warp_design: Option<Arc<Array2<f64>>>,
47}
48
49pub(crate) struct BinomialMeanWiggleGeometry {
50    pub(crate) basis: Array2<f64>,
51    pub(crate) basis_d1: Array2<f64>,
52    pub(crate) basis_d2: Array2<f64>,
53    pub(crate) basis_d3: Array2<f64>,
54    pub(crate) dq_dq0: Array1<f64>,
55    pub(crate) d2q_dq02: Array1<f64>,
56    pub(crate) d3q_dq03: Array1<f64>,
57    pub(crate) d4q_dq04: Array1<f64>,
58}
59
60pub(crate) struct BinomialMeanWiggleJointPsiDirection {
61    pub(crate) x_eta_psi: Option<Array2<f64>>,
62    pub(crate) z_eta_psi: Array1<f64>,
63}
64
65impl BinomialMeanWiggleFamily {
66    /// Check a caller-supplied block-state set against this family's fixed
67    /// two-block layout (η, wiggle).
68    ///
69    /// The per-block hooks are handed the whole state set alongside a
70    /// `block_idx` into it, so a set of the wrong length means `block_idx` is
71    /// indexing a layout this family does not own. Callers are permitted to
72    /// pass an empty slice when they have no states to offer (the constraint
73    /// cone and the non-negativity projection are both β-local), and an empty
74    /// slice makes no layout claim to check.
75    fn check_supplied_block_states(
76        &self,
77        block_states: &[ParameterBlockState],
78    ) -> Result<(), String> {
79        if block_states.is_empty() {
80            return Ok(());
81        }
82        validate_block_count::<String>("BinomialMeanWiggleFamily", 2, block_states.len())
83    }
84
85    pub const BLOCK_ETA: usize = 0;
86    pub const BLOCK_WIGGLE: usize = 1;
87
88    pub(crate) fn wiggle_basiswith_options(
89        &self,
90        q0: ArrayView1<'_, f64>,
91        options: BasisOptions,
92    ) -> Result<Array2<f64>, String> {
93        monotone_wiggle_basis_with_derivative_order(
94            q0,
95            &self.wiggle_knots,
96            self.wiggle_degree,
97            options.derivative_order,
98        )
99    }
100
101    pub(crate) fn wiggle_design(&self, q0: ArrayView1<'_, f64>) -> Result<Array2<f64>, String> {
102        self.wiggle_basiswith_options(q0, BasisOptions::value())
103    }
104
105    pub(crate) fn wiggle_dq_dq0(
106        &self,
107        q0: ArrayView1<'_, f64>,
108        beta_link_wiggle: ArrayView1<'_, f64>,
109    ) -> Result<Array1<f64>, String> {
110        let d_constrained = self.wiggle_basiswith_options(q0, BasisOptions::first_derivative())?;
111        if d_constrained.ncols() != beta_link_wiggle.len() {
112            return Err(GamlssError::DimensionMismatch { reason: format!(
113                "wiggle derivative/beta mismatch: basis has {} columns but beta_link_wiggle has {} coefficients",
114                d_constrained.ncols(),
115                beta_link_wiggle.len()
116            ) }.into());
117        }
118        Ok(d_constrained.dot(&beta_link_wiggle) + 1.0)
119    }
120
121    pub(crate) fn wiggle_d2q_dq02(
122        &self,
123        q0: ArrayView1<'_, f64>,
124        beta_link_wiggle: ArrayView1<'_, f64>,
125    ) -> Result<Array1<f64>, String> {
126        let d2 = self.wiggle_basiswith_options(q0, BasisOptions::second_derivative())?;
127        if d2.ncols() != beta_link_wiggle.len() {
128            return Err(GamlssError::DimensionMismatch { reason: format!(
129                "wiggle second-derivative/beta mismatch: basis has {} columns but beta_link_wiggle has {} coefficients",
130                d2.ncols(),
131                beta_link_wiggle.len()
132            ) }.into());
133        }
134        Ok(d2.dot(&beta_link_wiggle))
135    }
136
137    pub(crate) fn wiggle_d3basis_constrained(
138        &self,
139        q0: ArrayView1<'_, f64>,
140    ) -> Result<Array2<f64>, String> {
141        monotone_wiggle_basis_with_derivative_order(q0, &self.wiggle_knots, self.wiggle_degree, 3)
142    }
143
144    pub(crate) fn wiggle_d3q_dq03(
145        &self,
146        q0: ArrayView1<'_, f64>,
147        beta_link_wiggle: ArrayView1<'_, f64>,
148    ) -> Result<Array1<f64>, String> {
149        let d3 = self.wiggle_d3basis_constrained(q0)?;
150        if d3.ncols() != beta_link_wiggle.len() {
151            return Err(GamlssError::DimensionMismatch { reason: format!(
152                "wiggle third-derivative/beta mismatch: basis has {} columns but beta_link_wiggle has {} coefficients",
153                d3.ncols(),
154                beta_link_wiggle.len()
155            ) }.into());
156        }
157        Ok(d3.dot(&beta_link_wiggle))
158    }
159
160    pub(crate) fn wiggle_d4q_dq04(
161        &self,
162        q0: ArrayView1<'_, f64>,
163        beta_link_wiggle: ArrayView1<'_, f64>,
164    ) -> Result<Array1<f64>, String> {
165        let d4 = monotone_wiggle_basis_with_derivative_order(
166            q0,
167            &self.wiggle_knots,
168            self.wiggle_degree,
169            4,
170        )?;
171        if d4.ncols() != beta_link_wiggle.len() {
172            return Err(GamlssError::DimensionMismatch { reason: format!(
173                "wiggle fourth-derivative/beta mismatch: basis has {} columns but beta_link_wiggle has {} coefficients",
174                d4.ncols(),
175                beta_link_wiggle.len()
176            ) }.into());
177        }
178        Ok(d4.dot(&beta_link_wiggle))
179    }
180
181    pub(crate) fn wiggle_geometry(
182        &self,
183        q0: ArrayView1<'_, f64>,
184        beta_link_wiggle: ArrayView1<'_, f64>,
185    ) -> Result<BinomialMeanWiggleGeometry, String> {
186        // Frozen-basis (#1596): the warp offset `s = B⊥·β_w` uses the pinned,
187        // identifiable design `B⊥ = (I-P_X)B(η̂)`, so it is a per-row constant w.r.t.
188        // the *live* linear predictor `η`. Then `q = η + s` gives `∂q/∂η = 1`
189        // exactly and every higher derivative of `q` in `η` vanishes, and the
190        // `∂B/∂η` chain bases drop out (the warp basis does not move with `η`).
191        // The value basis `B⊥` — the column block carrying `∂q/∂β_w` — is the
192        // only surviving geometry term.
193        if let Some(frozen) = self.frozen_warp_design.as_ref() {
194            let n = frozen.nrows();
195            let pw = frozen.ncols();
196            return Ok(BinomialMeanWiggleGeometry {
197                basis: frozen.as_ref().clone(),
198                basis_d1: Array2::zeros((n, pw)),
199                basis_d2: Array2::zeros((n, pw)),
200                basis_d3: Array2::zeros((n, pw)),
201                dq_dq0: Array1::ones(n),
202                d2q_dq02: Array1::zeros(n),
203                d3q_dq03: Array1::zeros(n),
204                d4q_dq04: Array1::zeros(n),
205            });
206        }
207        let basis = self.wiggle_design(q0)?;
208        let basis_d1 = self.wiggle_basiswith_options(q0, BasisOptions::first_derivative())?;
209        let basis_d2 = self.wiggle_basiswith_options(q0, BasisOptions::second_derivative())?;
210        let basis_d3 = self.wiggle_d3basis_constrained(q0)?;
211        let dq_dq0 = self.wiggle_dq_dq0(q0, beta_link_wiggle)?;
212        let d2q_dq02 = self.wiggle_d2q_dq02(q0, beta_link_wiggle)?;
213        let d3q_dq03 = self.wiggle_d3q_dq03(q0, beta_link_wiggle)?;
214        let d4q_dq04 = self.wiggle_d4q_dq04(q0, beta_link_wiggle)?;
215        Ok(BinomialMeanWiggleGeometry {
216            basis,
217            basis_d1,
218            basis_d2,
219            basis_d3,
220            dq_dq0,
221            d2q_dq02,
222            d3q_dq03,
223            d4q_dq04,
224        })
225    }
226
227    pub(crate) fn neglog_q_derivatives(
228        &self,
229        y: f64,
230        weight: f64,
231        q: f64,
232    ) -> Result<(f64, f64, f64), String> {
233        let jet = inverse_link_jet_for_inverse_link(&self.link_kind, q)
234            .map_err(|e| format!("fixed-link wiggle inverse-link evaluation failed: {e}"))?;
235        // Pass μ RAW: the dispatch returns the exact q-derivatives of the
236        // evaluated loss for every representable μ in (0,1) and handles the
237        // saturated boundary itself. See binomial_location_scalerow (#948).
238        Ok(binomial_neglog_q_derivatives_dispatch(
239            y,
240            weight,
241            q,
242            jet.mu,
243            jet.d1,
244            jet.d2,
245            jet.d3,
246            &self.link_kind,
247        ))
248    }
249
250    pub(crate) fn neglog_q_fourth_derivative(
251        &self,
252        y: f64,
253        weight: f64,
254        q: f64,
255    ) -> Result<f64, String> {
256        let jet = inverse_link_jet_for_inverse_link(&self.link_kind, q)
257            .map_err(|e| format!("fixed-link wiggle inverse-link evaluation failed: {e}"))?;
258        // Pass μ RAW — see neglog_q_derivatives above (#948).
259        binomial_neglog_q_fourth_derivative_dispatch(
260            y,
261            weight,
262            q,
263            jet.mu,
264            jet.d1,
265            jet.d2,
266            jet.d3,
267            &self.link_kind,
268        )
269    }
270
271    pub(crate) fn dense_eta_design_fromspecs<'a>(
272        &self,
273        specs: &'a [ParameterBlockSpec],
274    ) -> Result<Cow<'a, Array2<f64>>, String> {
275        if specs.len() != 2 {
276            return Err(GamlssError::DimensionMismatch {
277                reason: format!(
278                    "BinomialMeanWiggleFamily expects 2 specs, got {}",
279                    specs.len()
280                ),
281            }
282            .into());
283        }
284        Ok(match specs[Self::BLOCK_ETA].design.as_dense_ref() {
285            Some(d) => Cow::Borrowed(d),
286            None => Cow::Owned(
287                specs[Self::BLOCK_ETA]
288                    .design
289                    .try_to_dense_with_policy(
290                        &self.policy.material_policy(),
291                        "BinomialMeanWiggle dense_eta_design_fromspecs eta",
292                    )
293                    .map_err(|e| e.to_string())?
294                    .as_ref()
295                    .clone(),
296            ),
297        })
298    }
299
300    pub(crate) fn exact_newton_joint_psi_direction(
301        &self,
302        block_states: &[ParameterBlockState],
303        derivative_blocks: &[Vec<CustomFamilyBlockPsiDerivative>],
304        psi_index: usize,
305        x_eta: &Array2<f64>,
306    ) -> Result<Option<BinomialMeanWiggleJointPsiDirection>, String> {
307        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
308        if derivative_blocks.len() != 2 {
309            return Err(GamlssError::DimensionMismatch { reason: format!(
310                "BinomialMeanWiggleFamily joint psi direction expects 2 derivative block lists, got {}",
311                derivative_blocks.len()
312            ) }.into());
313        }
314        let n = self.y.len();
315        let p_eta = x_eta.ncols();
316        let beta_eta = &block_states[Self::BLOCK_ETA].beta;
317        let mut global = 0usize;
318        for (block_idx, block_derivs) in derivative_blocks.iter().enumerate() {
319            for deriv in block_derivs {
320                if global == psi_index {
321                    if block_idx != Self::BLOCK_ETA {
322                        return Ok(None);
323                    }
324                    let x_eta_psi_map = resolve_custom_family_x_psi_map(
325                        deriv,
326                        n,
327                        p_eta,
328                        0..n,
329                        "BinomialMeanWiggleFamily eta",
330                        &self.policy,
331                    ).map_err(|error| error.to_string())?;
332                    let x_eta_psi = x_eta_psi_map.row_chunk(0..n).map_err(|error| error.to_string())?;
333                    let z_eta_psi = x_eta_psi.dot(beta_eta);
334                    return Ok(Some(BinomialMeanWiggleJointPsiDirection {
335                        x_eta_psi: Some(x_eta_psi),
336                        z_eta_psi,
337                    }));
338                }
339                global += 1;
340            }
341        }
342        Ok(None)
343    }
344
345    pub(crate) fn exact_newton_joint_psi_action(
346        &self,
347        block_states: &[ParameterBlockState],
348        derivative_blocks: &[Vec<CustomFamilyBlockPsiDerivative>],
349        psi_index: usize,
350        p_eta: usize,
351    ) -> Result<Option<(CustomFamilyPsiDesignAction, Array1<f64>)>, String> {
352        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
353        if derivative_blocks.len() != 2 {
354            return Err(GamlssError::DimensionMismatch { reason: format!(
355                "BinomialMeanWiggleFamily joint psi action expects 2 derivative block lists, got {}",
356                derivative_blocks.len()
357            ) }.into());
358        }
359        let n = self.y.len();
360        let beta_eta = &block_states[Self::BLOCK_ETA].beta;
361        let mut global = 0usize;
362        for (block_idx, block_derivs) in derivative_blocks.iter().enumerate() {
363            for deriv in block_derivs {
364                if global == psi_index {
365                    if block_idx != Self::BLOCK_ETA {
366                        return Ok(None);
367                    }
368                    let action = match CustomFamilyPsiDesignAction::from_first_derivative(
369                        deriv,
370                        n,
371                        p_eta,
372                        0..n,
373                        "BinomialMeanWiggleFamily eta",
374                    ) {
375                        Ok(action) => action,
376                        Err(_) => return Ok(None),
377                    };
378                    let z_eta_psi = action.forward_mul(beta_eta.view());
379                    return Ok(Some((action, z_eta_psi)));
380                }
381                global += 1;
382            }
383        }
384        Ok(None)
385    }
386
387    pub(crate) fn bmw_static_hessian_operator(
388        &self,
389        block_states: &[ParameterBlockState],
390        x_eta_arc: Arc<Array2<f64>>,
391    ) -> Result<Arc<RowCoeffOperator>, String> {
392        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
393        let eta = &block_states[Self::BLOCK_ETA].eta;
394        let etaw = &block_states[Self::BLOCK_WIGGLE].eta;
395        let betaw = &block_states[Self::BLOCK_WIGGLE].beta;
396        let n = self.y.len();
397        if eta.len() != n || etaw.len() != n || self.weights.len() != n {
398            return Err(GamlssError::DimensionMismatch {
399                reason: "BinomialMeanWiggleFamily input size mismatch".to_string(),
400            }
401            .into());
402        }
403        let geom = self.wiggle_geometry(eta.view(), betaw.view())?;
404        let p_eta = x_eta_arc.ncols();
405        let pw = geom.basis.ncols();
406        let mut coeff_eta = Array1::<f64>::zeros(n);
407        let mut coeff_etaw_b = Array1::<f64>::zeros(n);
408        let mut coeff_etaw_d1 = Array1::<f64>::zeros(n);
409        let mut coeff_ww = Array1::<f64>::zeros(n);
410        for row in 0..n {
411            let q = eta[row] + etaw[row];
412            let (m1, m2, _) = self.neglog_q_derivatives(self.y[row], self.weights[row], q)?;
413            let a = geom.dq_dq0[row];
414            let b = geom.d2q_dq02[row];
415            coeff_eta[row] = hessian_coeff_fromobjective_q_terms(m1, m2, a, a, b);
416            coeff_etaw_b[row] = m2 * a;
417            coeff_etaw_d1[row] = m1;
418            coeff_ww[row] = m2;
419        }
420        Ok(Arc::new(RowCoeffOperator::from_directions(
421            vec![p_eta, pw],
422            vec![
423                (0, x_eta_arc),
424                (1, Arc::new(geom.basis)),
425                (1, Arc::new(geom.basis_d1)),
426            ],
427            vec![
428                (0, 0, coeff_eta),
429                (0, 1, coeff_etaw_b),
430                (0, 2, coeff_etaw_d1),
431                (1, 1, coeff_ww),
432            ],
433            n,
434        )))
435    }
436
437    pub(crate) fn bmw_directional_operator(
438        &self,
439        block_states: &[ParameterBlockState],
440        x_eta_arc: Arc<Array2<f64>>,
441        d_beta_flat: &Array1<f64>,
442    ) -> Result<Option<Arc<dyn gam_problem::HyperOperator>>, String> {
443        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
444        let eta = &block_states[Self::BLOCK_ETA].eta;
445        let etaw = &block_states[Self::BLOCK_WIGGLE].eta;
446        let betaw = &block_states[Self::BLOCK_WIGGLE].beta;
447        let n = self.y.len();
448        if eta.len() != n || etaw.len() != n || self.weights.len() != n {
449            return Err(GamlssError::DimensionMismatch {
450                reason: "BinomialMeanWiggleFamily input size mismatch".to_string(),
451            }
452            .into());
453        }
454        let geom = self.wiggle_geometry(eta.view(), betaw.view())?;
455        let p_eta = x_eta_arc.ncols();
456        let pw = geom.basis.ncols();
457        let total = p_eta + pw;
458        if d_beta_flat.len() != total {
459            return Err(GamlssError::DimensionMismatch {
460                reason: format!(
461                    "BinomialMeanWiggleFamily joint d_beta length mismatch: got {}, expected {}",
462                    d_beta_flat.len(),
463                    total
464                ),
465            }
466            .into());
467        }
468        let u_eta = d_beta_flat.slice(s![0..p_eta]).to_owned();
469        let uw = d_beta_flat.slice(s![p_eta..total]).to_owned();
470        let xi = fast_av(x_eta_arc.as_ref(), &u_eta);
471        let phi = fast_av(&geom.basis, &uw);
472        let basis1_u = fast_av(&geom.basis_d1, &uw);
473        let basis2_u = fast_av(&geom.basis_d2, &uw);
474
475        let mut coeff_eta = Array1::<f64>::zeros(n);
476        let mut coeff_etaw_b = Array1::<f64>::zeros(n);
477        let mut coeff_etaw_d1 = Array1::<f64>::zeros(n);
478        let mut coeff_etaw_d2 = Array1::<f64>::zeros(n);
479        let mut coeff_ww_bb = Array1::<f64>::zeros(n);
480        let mut coeff_ww_db = Array1::<f64>::zeros(n);
481        for row in 0..n {
482            let q = eta[row] + etaw[row];
483            let (m1, m2, m3) = self.neglog_q_derivatives(self.y[row], self.weights[row], q)?;
484            let a = geom.dq_dq0[row];
485            let b = geom.d2q_dq02[row];
486            let c = geom.d3q_dq03[row];
487            let q_u = a * xi[row] + phi[row];
488            let a_u = b * xi[row] + basis1_u[row];
489            let b_u = c * xi[row] + basis2_u[row];
490            let [eta_eta, eta_w_b, eta_w_d1, eta_w_d2, ww_bb, ww_db] =
491                mean_wiggle_directional_coefficients(
492                    [m1, m2, m3],
493                    q_u,
494                    a,
495                    b,
496                    a_u,
497                    b_u,
498                    xi[row],
499                );
500            coeff_eta[row] = eta_eta;
501            coeff_etaw_b[row] = eta_w_b;
502            coeff_etaw_d1[row] = eta_w_d1;
503            coeff_etaw_d2[row] = eta_w_d2;
504            coeff_ww_bb[row] = ww_bb;
505            coeff_ww_db[row] = ww_db;
506        }
507        Ok(Some(Arc::new(RowCoeffOperator::from_directions(
508            vec![p_eta, pw],
509            vec![
510                (0, x_eta_arc),
511                (1, Arc::new(geom.basis)),
512                (1, Arc::new(geom.basis_d1)),
513                (1, Arc::new(geom.basis_d2)),
514            ],
515            vec![
516                (0, 0, coeff_eta),
517                (0, 1, coeff_etaw_b),
518                (0, 2, coeff_etaw_d1),
519                (0, 3, coeff_etaw_d2),
520                (1, 1, coeff_ww_bb),
521                (1, 2, coeff_ww_db),
522            ],
523            n,
524        ))))
525    }
526
527    pub(crate) fn bmw_second_directional_operator(
528        &self,
529        block_states: &[ParameterBlockState],
530        x_eta_arc: Arc<Array2<f64>>,
531        d_beta_u_flat: &Array1<f64>,
532        d_beta_v_flat: &Array1<f64>,
533    ) -> Result<Option<Arc<dyn gam_problem::HyperOperator>>, String> {
534        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
535        let eta = &block_states[Self::BLOCK_ETA].eta;
536        let etaw = &block_states[Self::BLOCK_WIGGLE].eta;
537        let betaw = &block_states[Self::BLOCK_WIGGLE].beta;
538        let n = self.y.len();
539        if eta.len() != n || etaw.len() != n || self.weights.len() != n {
540            return Err(GamlssError::DimensionMismatch {
541                reason: "BinomialMeanWiggleFamily input size mismatch".to_string(),
542            }
543            .into());
544        }
545        let geom = self.wiggle_geometry(eta.view(), betaw.view())?;
546        let p_eta = x_eta_arc.ncols();
547        let pw = geom.basis.ncols();
548        let total = p_eta + pw;
549        if d_beta_u_flat.len() != total || d_beta_v_flat.len() != total {
550            return Err(GamlssError::DimensionMismatch { reason: format!(
551                "BinomialMeanWiggleFamily joint second d_beta length mismatch: got {} and {}, expected {}",
552                d_beta_u_flat.len(),
553                d_beta_v_flat.len(),
554                total
555            ) }.into());
556        }
557        let u_eta = d_beta_u_flat.slice(s![0..p_eta]).to_owned();
558        let v_eta = d_beta_v_flat.slice(s![0..p_eta]).to_owned();
559        let uw = d_beta_u_flat.slice(s![p_eta..total]).to_owned();
560        let vw = d_beta_v_flat.slice(s![p_eta..total]).to_owned();
561
562        let xi_u = fast_av(x_eta_arc.as_ref(), &u_eta);
563        let xi_v = fast_av(x_eta_arc.as_ref(), &v_eta);
564        let phi_u = fast_av(&geom.basis, &uw);
565        let phi_v = fast_av(&geom.basis, &vw);
566        let b1u = fast_av(&geom.basis_d1, &uw);
567        let b1v = fast_av(&geom.basis_d1, &vw);
568        let b2u = fast_av(&geom.basis_d2, &uw);
569        let b2v = fast_av(&geom.basis_d2, &vw);
570        let b3u = fast_av(&geom.basis_d3, &uw);
571        let b3v = fast_av(&geom.basis_d3, &vw);
572
573        let mut coeff_eta = Array1::<f64>::zeros(n);
574        let mut coeff_etaw_b = Array1::<f64>::zeros(n);
575        let mut coeff_etaw_d1 = Array1::<f64>::zeros(n);
576        let mut coeff_etaw_d2 = Array1::<f64>::zeros(n);
577        let mut coeff_etaw_d3 = Array1::<f64>::zeros(n);
578        let mut coeff_ww_bb = Array1::<f64>::zeros(n);
579        let mut coeff_ww_db = Array1::<f64>::zeros(n);
580        let mut coeff_ww_ddb = Array1::<f64>::zeros(n);
581        let mut coeff_ww_dd = Array1::<f64>::zeros(n);
582
583        for row in 0..n {
584            let q = eta[row] + etaw[row];
585            let (m1, m2, m3) = self.neglog_q_derivatives(self.y[row], self.weights[row], q)?;
586            let m4 = self.neglog_q_fourth_derivative(self.y[row], self.weights[row], q)?;
587            let a = geom.dq_dq0[row];
588            let b = geom.d2q_dq02[row];
589            let c = geom.d3q_dq03[row];
590            let d = geom.d4q_dq04[row];
591
592            let q_u = a * xi_u[row] + phi_u[row];
593            let a_u = b * xi_u[row] + b1u[row];
594            let b_u = c * xi_u[row] + b2u[row];
595            let q_v = a * xi_v[row] + phi_v[row];
596            let a_v = b * xi_v[row] + b1v[row];
597            let b_v = c * xi_v[row] + b2v[row];
598            let q_uv = b * xi_u[row] * xi_v[row] + b1u[row] * xi_v[row] + b1v[row] * xi_u[row];
599            let a_uv = c * xi_u[row] * xi_v[row] + b2u[row] * xi_v[row] + b2v[row] * xi_u[row];
600            let b_uv = d * xi_u[row] * xi_v[row] + b3u[row] * xi_v[row] + b3v[row] * xi_u[row];
601
602            let [
603                eta_eta,
604                eta_w_b,
605                eta_w_d1,
606                eta_w_d2,
607                eta_w_d3,
608                ww_bb,
609                ww_db,
610                ww_ddb,
611                ww_dd,
612            ] = mean_wiggle_second_directional_coefficients(
613                    [m1, m2, m3, m4],
614                    [q_u, q_v, q_uv],
615                    [a, a_u, a_v, a_uv],
616                    [b, b_u, b_v, b_uv],
617                    [xi_u[row], xi_v[row]],
618                );
619            coeff_eta[row] = eta_eta;
620            coeff_etaw_b[row] = eta_w_b;
621            coeff_etaw_d1[row] = eta_w_d1;
622            coeff_etaw_d2[row] = eta_w_d2;
623            coeff_etaw_d3[row] = eta_w_d3;
624            coeff_ww_bb[row] = ww_bb;
625            coeff_ww_db[row] = ww_db;
626            coeff_ww_ddb[row] = ww_ddb;
627            coeff_ww_dd[row] = ww_dd;
628        }
629
630        Ok(Some(Arc::new(RowCoeffOperator::from_directions(
631            vec![p_eta, pw],
632            vec![
633                (0, x_eta_arc),
634                (1, Arc::new(geom.basis)),
635                (1, Arc::new(geom.basis_d1)),
636                (1, Arc::new(geom.basis_d2)),
637                (1, Arc::new(geom.basis_d3)),
638            ],
639            vec![
640                (0, 0, coeff_eta),
641                (0, 1, coeff_etaw_b),
642                (0, 2, coeff_etaw_d1),
643                (0, 3, coeff_etaw_d2),
644                (0, 4, coeff_etaw_d3),
645                (1, 1, coeff_ww_bb),
646                (1, 2, coeff_ww_db),
647                (1, 3, coeff_ww_ddb),
648                (2, 2, coeff_ww_dd),
649            ],
650            n,
651        ))))
652    }
653
654    /// Build the [`BlockEffectiveJacobian`] for block `block_idx`.
655    ///
656    /// `BinomialMeanWiggle` has a single location output (n_outputs = 1):
657    /// - block 0 (eta):    output 0 = design rows
658    /// - block 1 (wiggle): all zeros (nonlinear link modulation)
659    pub fn block_effective_jacobian(
660        specs: &[ParameterBlockSpec],
661        block_idx: usize,
662    ) -> Result<Box<dyn BlockEffectiveJacobian>, String> {
663        crate::block_layout::block_jacobian::AdditiveWiggleBlockLayout {
664            family: "BinomialMeanWiggleFamily",
665            n_outputs: 1,
666            additive_blocks: &[Self::BLOCK_ETA],
667            wiggle_block: Some(Self::BLOCK_WIGGLE),
668        }
669        .block_effective_jacobian(specs, block_idx)
670    }
671}
672
673impl CustomFamily for BinomialMeanWiggleFamily {
674    fn exact_newton_joint_hessian_beta_dependent(&self) -> bool {
675        true
676    }
677
678    /// The binomial mean link-wiggle refit must NOT carry the full-span
679    /// Jeffreys/Firth augmentation, for the same structural reason
680    /// `GaussianLocationScaleWiggleFamily` opts out (#684–#688) — and the
681    /// binomial wiggle hits it harder. This is a *second-stage* refit: the
682    /// pilot binomial mean fit has already converged through the ordinary
683    /// PIRLS path (which is itself un-Firthed unless the user opts in — the
684    /// standard binomial fit logs `firth=false` / `jeffreys_logdet=none`), so
685    /// the wiggle refit only adds a *penalized*, *monotone-constrained*
686    /// I-spline link-shape correction `q = η + B(η)·β_w` around an
687    /// already-finite mode. Two failure modes follow from leaving the term on
688    /// (default `true`):
689    ///
690    /// 1. **Phantom stationarity residual.** When `H_pen` is full-rank and
691    ///    well-conditioned (the normal case — e.g. `cond ≈ 5.5e2` on the #872
692    ///    pure-probit repro) the Jeffreys gate smooth-steps the curvature
693    ///    `H_Φ → 0`, but the matching score `∇Φ` does not vanish in lock-step,
694    ///    so it leaks a nonzero `|∇L − Sβ + ∇Φ|` into the inner joint-Newton
695    ///    KKT residual. The certificate then refuses every iterate and the
696    ///    outer REML rejects all seeds (exactly the #684–#688 abort signature).
697    /// 2. **Saturation barrier / divergence.** `−Φ = −½log|I_J|` is folded into
698    ///    the objective and `∇Φ ∝ I_J⁻¹` into the gradient. The I-spline warp
699    ///    can drive the binomial linear predictor toward saturation, where the
700    ///    reduced Fisher information `I_J` goes singular: `−Φ → +∞` and
701    ///    `∇Φ → ∞`. The augmented objective grows a barrier that the joint
702    ///    Newton diverges into — the #872 repro runs the full 1200-cycle budget
703    ///    with the augmented objective pinned at ~4.6e9 and the augmented
704    ///    residual at ~5.8e9 while the plain data gradient is only ~2.3e2,
705    ///    aborting the documented `link(type=flexible(...)) + linkwiggle(...)`
706    ///    fit.
707    ///
708    /// Separation robustness is not lost: the wiggle block carries both a
709    /// difference penalty (λ selected by REML) and a hard non-negativity
710    /// constraint, and the underlying mean is fit by the pilot; a penalized,
711    /// constrained refit around a finite pilot mode does not run away to
712    /// `β → ∞` the way an unpenalized MLE can. Turning the term off here makes
713    /// the wiggle refit consistent with the un-Firthed pilot and removes the
714    /// phantom residual that blocked convergence.
715    fn joint_jeffreys_term_required(&self) -> bool {
716        false
717    }
718
719    fn coefficient_hessian_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
720        // The mean-wiggle Hessian is exposed as a row-coefficient operator,
721        // so the hot representation cost is one Θ(n · (p_eta + p_w)) HVP
722        // rather than dense Θ(n · (p_eta + p_w)^2) assembly.
723        let p_total = specs
724            .iter()
725            .map(|s| s.design.ncols() as u64)
726            .fold(0u64, |acc, p| acc.saturating_add(p));
727        (self.y.len() as u64).saturating_mul(p_total.max(1))
728    }
729
730    fn block_linear_constraints(
731        &self,
732        block_states: &[ParameterBlockState],
733        block_idx: usize,
734        spec: &ParameterBlockSpec,
735    ) -> Result<Option<ConstraintSet>, String> {
736        self.check_supplied_block_states(block_states)?;
737        if block_idx != Self::BLOCK_WIGGLE {
738            return Ok(None);
739        }
740        // Frozen-basis residualization preserves the original I-spline
741        // coefficient coordinate, so the same exact non-negative cone applies
742        // in both dynamic and frozen modes. For β_w ≥ 0, the M-spline
743        // derivative gives dq/dη = 1 + B'(η)·β_w ≥ 1 everywhere.
744        Ok(monotone_wiggle_nonnegative_constraints(spec.design.ncols()))
745    }
746
747    fn post_update_block_beta(
748        &self,
749        block_states: &[ParameterBlockState],
750        block_idx: usize,
751        block_spec: &ParameterBlockSpec,
752        beta: Array1<f64>,
753    ) -> Result<Array1<f64>, String> {
754        self.check_supplied_block_states(block_states)?;
755        assert!(!block_spec.name.is_empty());
756        if block_idx != Self::BLOCK_WIGGLE {
757            return Ok(beta);
758        }
759        let beta = project_monotone_wiggle_beta_nonnegative(beta);
760        validate_monotone_wiggle_beta_nonnegative(&beta, "BinomialMeanWiggleFamily post-update")?;
761        Ok(beta)
762    }
763
764    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
765        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
766        let eta = &block_states[Self::BLOCK_ETA].eta;
767        let etaw = &block_states[Self::BLOCK_WIGGLE].eta;
768        let betaw = &block_states[Self::BLOCK_WIGGLE].beta;
769        let n = self.y.len();
770        if eta.len() != n || etaw.len() != n || self.weights.len() != n {
771            return Err(GamlssError::DimensionMismatch {
772                reason: "BinomialMeanWiggleFamily input size mismatch".to_string(),
773            }
774            .into());
775        }
776        // Frozen-basis (#1596): with `B⊥` pinned, `q = η + B⊥·β_w` so
777        // `∂q/∂η = 1` exactly (the warp offset is constant in the live `η`).
778        // Otherwise `∂q/∂η = 1 + B'(η)·β_w`, the dynamic warp slope.
779        let dq_dq0 = if self.frozen_warp_design.is_some() {
780            Array1::<f64>::ones(n)
781        } else {
782            self.wiggle_dq_dq0(eta.view(), betaw.view())?
783        };
784        if dq_dq0.len() != n {
785            return Err(GamlssError::DimensionMismatch {
786                reason: format!(
787                    "BinomialMeanWiggleFamily dq/dq0 length mismatch: got {}, expected {}",
788                    dq_dq0.len(),
789                    n
790                ),
791            }
792            .into());
793        }
794
795        // Certify the entire batch before constructing any working array.  The
796        // q-space NLL derivative stack is the single source of truth for both
797        // the objective and the working geometry; probability/variance and
798        // derivative floors would define a different loss in the tails.
799        let mut rows = Vec::with_capacity(n);
800        for i in 0..n {
801            let q = eta[i] + etaw[i];
802            if !eta[i].is_finite() || !etaw[i].is_finite() || !q.is_finite() {
803                return Err(GamlssError::RowGeometryUnrepresentable {
804                    row: i,
805                    quantity: "binomial mean-wiggle predictor q",
806                    eta: eta[i],
807                    value: q,
808                }
809                .into());
810            }
811            let yi = self.y[i];
812            let wi = self.weights[i];
813            if !yi.is_finite() || !(0.0..=1.0).contains(&yi) {
814                return Err(GamlssError::InvalidInput {
815                    reason: format!(
816                        "BinomialMeanWiggleFamily requires y in [0, 1]; found y[{i}]={yi}"
817                    ),
818                }
819                .into());
820            }
821            if !wi.is_finite() || wi < 0.0 {
822                return Err(GamlssError::InvalidInput {
823                    reason: format!(
824                        "BinomialMeanWiggleFamily requires finite non-negative weights; found weight[{i}]={wi}"
825                    ),
826                }
827                .into());
828            }
829            let slope = dq_dq0[i];
830            if !slope.is_finite() {
831                return Err(GamlssError::RowGeometryUnrepresentable {
832                    row: i,
833                    quantity: "binomial mean-wiggle warp slope",
834                    eta: eta[i],
835                    value: slope,
836                }
837                .into());
838            }
839            if wi == 0.0 {
840                rows.push((0.0, eta[i], 0.0, etaw[i], 0.0));
841                continue;
842            }
843            let jet = inverse_link_jet_for_inverse_link(&self.link_kind, q)
844                .map_err(|e| format!("fixed-link wiggle inverse-link evaluation failed: {e}"))?;
845            let row_ll =
846                binomial_location_scale_log_likelihood(yi, wi, q, &self.link_kind, jet.mu)?;
847            // The objective and its gradient stay EXACT: `row_ll` is the true
848            // NLL and `m1` its exact q-space score. The working *curvature*,
849            // however, is the EXPECTED Fisher information
850            //   f = w·(μ'(q))² / (μ(1−μ)) ≥ 0,
851            // NOT the observed second derivative
852            //   m2 = −(ℓ''(μ)·μ'² + ℓ'(μ)·μ'').
853            // For a non-canonical binomial link (cauchit / loglog / …) the
854            // observed `m2` can go NEGATIVE at a row whose fitted probability
855            // disagrees with its response — cauchit at q≈1.248 with y=0 gives
856            // m2=−0.23 — because the ℓ'·μ'' term is unbounded below there. That
857            // is a genuine property of the observed Hessian, not an
858            // unrepresentable row, so keying representability off `m2 > 0`
859            // wrongly refused every such link (gam#2155). Fisher scoring uses the
860            // expected information, which is ≥ 0 for every interior μ, so the
861            // per-block working weight `X'WX` is PSD by construction and the
862            // inner solve and the outer REML `log|X'WX + S|` term are
863            // well-defined for every binomial link. This mirrors the
864            // location-scale sibling's expected-information choice
865            // (gam#1020 / gam#2353); the fixed point (score = 0) is identical to
866            // the observed-Newton one, only the iteration geometry changes.
867            let (m1, _, _) = binomial_neglog_q_derivatives_dispatch(
868                yi,
869                wi,
870                q,
871                jet.mu,
872                jet.d1,
873                jet.d2,
874                jet.d3,
875                &self.link_kind,
876            );
877            let (fisher, _, _) =
878                binomial_expected_q_information_derivatives(wi, jet.mu, jet.d1, jet.d2, jet.d3);
879            for (quantity, value, nonnegative) in [
880                ("binomial mean-wiggle row log likelihood", row_ll, false),
881                ("binomial mean-wiggle q score", m1, false),
882                ("binomial mean-wiggle expected q information", fisher, true),
883            ] {
884                if !value.is_finite() || (nonnegative && value < 0.0) {
885                    return Err(GamlssError::RowGeometryUnrepresentable {
886                        row: i,
887                        quantity,
888                        eta: q,
889                        value,
890                    }
891                    .into());
892                }
893            }
894            // η block working geometry — dead when the warp slope vanishes or the
895            // row carries no expected information (saturated μ).
896            let (z_eta_i, w_eta_i) = if slope == 0.0 || fisher == 0.0 {
897                (eta[i], 0.0)
898            } else {
899                let weight = fisher * slope * slope;
900                if !weight.is_finite() || weight <= 0.0 {
901                    return Err(GamlssError::RowGeometryUnrepresentable {
902                        row: i,
903                        quantity: "binomial mean-wiggle eta working weight",
904                        eta: eta[i],
905                        value: weight,
906                    }
907                    .into());
908                }
909                let response = eta[i] - m1 / (fisher * slope);
910                if !response.is_finite() {
911                    return Err(GamlssError::RowGeometryUnrepresentable {
912                        row: i,
913                        quantity: "binomial mean-wiggle eta working response",
914                        eta: eta[i],
915                        value: response,
916                    }
917                    .into());
918                }
919                (response, weight)
920            };
921            // wiggle block working geometry — dead when the row carries no
922            // expected information.
923            let (z_wiggle_i, w_wiggle_i) = if fisher == 0.0 {
924                (etaw[i], 0.0)
925            } else {
926                let z_wiggle_i = etaw[i] - m1 / fisher;
927                if !z_wiggle_i.is_finite() {
928                    return Err(GamlssError::RowGeometryUnrepresentable {
929                        row: i,
930                        quantity: "binomial mean-wiggle wiggle working response",
931                        eta: etaw[i],
932                        value: z_wiggle_i,
933                    }
934                    .into());
935                }
936                (z_wiggle_i, fisher)
937            };
938            rows.push((row_ll, z_eta_i, w_eta_i, z_wiggle_i, w_wiggle_i));
939        }
940
941        let mut ll = 0.0;
942        for (i, row) in rows.iter().enumerate() {
943            ll += row.0;
944            if !ll.is_finite() {
945                return Err(GamlssError::RowGeometryUnrepresentable {
946                    row: i,
947                    quantity: "binomial mean-wiggle cumulative log likelihood",
948                    eta: eta[i],
949                    value: ll,
950                }
951                .into());
952            }
953        }
954        let z_eta = Array1::from_iter(rows.iter().map(|row| row.1));
955        let w_eta = Array1::from_iter(rows.iter().map(|row| row.2));
956        let z_wiggle = Array1::from_iter(rows.iter().map(|row| row.3));
957        let w_wiggle = Array1::from_iter(rows.iter().map(|row| row.4));
958
959        Ok(FamilyEvaluation {
960            log_likelihood: ll,
961            blockworking_sets: vec![
962                BlockWorkingSet::diagonal_checked(z_eta, w_eta)?,
963                BlockWorkingSet::diagonal_checked(z_wiggle, w_wiggle)?,
964            ],
965        })
966    }
967
968    fn exact_newton_joint_gradient_evaluation(
969        &self,
970        block_states: &[ParameterBlockState],
971        specs: &[ParameterBlockSpec],
972    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
973        // Assemble the exact joint score from the per-block IRLS working sets
974        // (X_bᵀ(w⊙(z−η)) per block), the same source of truth the inner
975        // joint-Newton RHS uses — consistent with the family's explicit joint
976        // Hessian and matching FD of the log-likelihood.
977        let eval = self.evaluate(block_states)?;
978        gamlss_joint_gradient_from_working_sets(&eval, specs, block_states).map(Some)
979    }
980
981    fn block_geometry(
982        &self,
983        block_states: &[ParameterBlockState],
984        spec: &ParameterBlockSpec,
985    ) -> Result<(DesignMatrix, Array1<f64>), String> {
986        if spec.name != "wiggle" {
987            return Ok((spec.design.clone(), spec.offset.clone()));
988        }
989        if block_states.is_empty() {
990            return Err(GamlssError::UnsupportedConfiguration {
991                reason: "wiggle geometry requires eta block".to_string(),
992            }
993            .into());
994        }
995        let eta = &block_states[Self::BLOCK_ETA].eta;
996        if eta.len() != self.y.len() {
997            return Err(GamlssError::DimensionMismatch {
998                reason: "BinomialMeanWiggleFamily eta size mismatch".to_string(),
999            }
1000            .into());
1001        }
1002        // Frozen-basis (#1596): return the pinned, identifiable warp design
1003        // `B⊥ = (I-P_X)B(η̂)` rather than the live `B(η)`. `B⊥` is constant across
1004        // inner cycles, so the engine rebuilds the *same* matrix every cycle —
1005        // the death-spiral source (a basis that moves under the line search) is
1006        // gone, while the dynamic-geometry plumbing is preserved unchanged.
1007        let x = match self.frozen_warp_design.as_ref() {
1008            Some(frozen) => frozen.as_ref().clone(),
1009            None => self.wiggle_design(eta.view())?,
1010        };
1011        if x.ncols() != spec.design.ncols() {
1012            return Err(GamlssError::DimensionMismatch {
1013                reason: format!(
1014                    "dynamic wiggle design col mismatch: got {}, expected {}",
1015                    x.ncols(),
1016                    spec.design.ncols()
1017                ),
1018            }
1019            .into());
1020        }
1021        let nrows = x.nrows();
1022        Ok((
1023            DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(x)),
1024            Array1::zeros(nrows),
1025        ))
1026    }
1027
1028    fn block_geometry_is_dynamic(&self) -> bool {
1029        true
1030    }
1031
1032    fn exact_newton_joint_hessian_workspace(
1033        &self,
1034        block_states: &[ParameterBlockState],
1035        specs: &[ParameterBlockSpec],
1036    ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
1037        let x_eta = self.dense_eta_design_fromspecs(specs)?.into_owned();
1038        let workspace = BinomialMeanWiggleHessianWorkspace::new(
1039            self.clone(),
1040            block_states.to_vec(),
1041            specs.to_vec(),
1042            x_eta,
1043        )?;
1044        Ok(Some(Arc::new(workspace)))
1045    }
1046
1047    fn inner_coefficient_hessian_hvp_available(&self, specs: &[ParameterBlockSpec]) -> bool {
1048        self.dense_eta_design_fromspecs(specs).is_ok()
1049    }
1050
1051    fn exact_newton_joint_hessian_with_specs(
1052        &self,
1053        block_states: &[ParameterBlockState],
1054        specs: &[ParameterBlockSpec],
1055    ) -> Result<Option<Array2<f64>>, String> {
1056        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
1057        let x_eta = self.dense_eta_design_fromspecs(specs)?;
1058        let eta = &block_states[Self::BLOCK_ETA].eta;
1059        let etaw = &block_states[Self::BLOCK_WIGGLE].eta;
1060        let betaw = &block_states[Self::BLOCK_WIGGLE].beta;
1061        let n = self.y.len();
1062        if eta.len() != n || etaw.len() != n || self.weights.len() != n {
1063            return Err(GamlssError::DimensionMismatch {
1064                reason: "BinomialMeanWiggleFamily input size mismatch".to_string(),
1065            }
1066            .into());
1067        }
1068        let geom = self.wiggle_geometry(eta.view(), betaw.view())?;
1069        let p_eta = x_eta.ncols();
1070        let pw = geom.basis.ncols();
1071        let mut coeff_eta = Array1::<f64>::zeros(n);
1072        let mut coeff_etaw_b = Array1::<f64>::zeros(n);
1073        let mut coeff_etaw_d1 = Array1::<f64>::zeros(n);
1074        let mut coeff_ww = Array1::<f64>::zeros(n);
1075        for row in 0..n {
1076            let q = eta[row] + etaw[row];
1077            let (m1, m2, _) = self.neglog_q_derivatives(self.y[row], self.weights[row], q)?;
1078            let a = geom.dq_dq0[row];
1079            let b = geom.d2q_dq02[row];
1080            coeff_eta[row] = hessian_coeff_fromobjective_q_terms(m1, m2, a, a, b);
1081            coeff_etaw_b[row] = m2 * a;
1082            coeff_etaw_d1[row] = m1;
1083            coeff_ww[row] = m2;
1084        }
1085        let h_eta_eta = xt_diag_x_dense(&x_eta, &coeff_eta)?;
1086        let h_eta_w = xt_diag_y_dense(&x_eta, &coeff_etaw_b, &geom.basis)?
1087            + &xt_diag_y_dense(&x_eta, &coeff_etaw_d1, &geom.basis_d1)?;
1088        let h_ww = xt_diag_x_dense(&geom.basis, &coeff_ww)?;
1089        assert_eq!(h_eta_eta.nrows(), p_eta);
1090        assert_eq!(h_ww.nrows(), pw);
1091        Ok(Some(binomial_pack_mean_wiggle_joint_symmetrichessian(
1092            &h_eta_eta, &h_eta_w, &h_ww,
1093        )))
1094    }
1095
1096    fn exact_newton_joint_hessian_directional_derivative_with_specs(
1097        &self,
1098        block_states: &[ParameterBlockState],
1099        specs: &[ParameterBlockSpec],
1100        d_beta_flat: &Array1<f64>,
1101    ) -> Result<Option<Array2<f64>>, String> {
1102        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
1103        let x_eta = self.dense_eta_design_fromspecs(specs)?;
1104        let eta = &block_states[Self::BLOCK_ETA].eta;
1105        let etaw = &block_states[Self::BLOCK_WIGGLE].eta;
1106        let betaw = &block_states[Self::BLOCK_WIGGLE].beta;
1107        let n = self.y.len();
1108        if eta.len() != n || etaw.len() != n || self.weights.len() != n {
1109            return Err(GamlssError::DimensionMismatch {
1110                reason: "BinomialMeanWiggleFamily input size mismatch".to_string(),
1111            }
1112            .into());
1113        }
1114        let geom = self.wiggle_geometry(eta.view(), betaw.view())?;
1115        let p_eta = x_eta.ncols();
1116        let pw = geom.basis.ncols();
1117        if d_beta_flat.len() != p_eta + pw {
1118            return Err(GamlssError::DimensionMismatch {
1119                reason: format!(
1120                    "BinomialMeanWiggleFamily joint d_beta length mismatch: got {}, expected {}",
1121                    d_beta_flat.len(),
1122                    p_eta + pw
1123                ),
1124            }
1125            .into());
1126        }
1127        let u_eta = d_beta_flat.slice(s![0..p_eta]).to_owned();
1128        let uw = d_beta_flat.slice(s![p_eta..p_eta + pw]).to_owned();
1129        let xi = x_eta.dot(&u_eta);
1130        let phi = geom.basis.dot(&uw);
1131        let basis1_u = geom.basis_d1.dot(&uw);
1132        let basis2_u = geom.basis_d2.dot(&uw);
1133
1134        let mut coeff_eta = Array1::<f64>::zeros(n);
1135        let mut coeff_etaw_b = Array1::<f64>::zeros(n);
1136        let mut coeff_etaw_d1 = Array1::<f64>::zeros(n);
1137        let mut coeff_etaw_d2 = Array1::<f64>::zeros(n);
1138        let mut coeff_ww_bb = Array1::<f64>::zeros(n);
1139        let mut coeff_ww_db = Array1::<f64>::zeros(n);
1140        for row in 0..n {
1141            let q = eta[row] + etaw[row];
1142            let (m1, m2, m3) = self.neglog_q_derivatives(self.y[row], self.weights[row], q)?;
1143            let a = geom.dq_dq0[row];
1144            let b = geom.d2q_dq02[row];
1145            let c = geom.d3q_dq03[row];
1146            let q_u = a * xi[row] + phi[row];
1147            let a_u = b * xi[row] + basis1_u[row];
1148            let b_u = c * xi[row] + basis2_u[row];
1149            let [eta_eta, eta_w_b, eta_w_d1, eta_w_d2, ww_bb, ww_db] =
1150                mean_wiggle_directional_coefficients(
1151                    [m1, m2, m3],
1152                    q_u,
1153                    a,
1154                    b,
1155                    a_u,
1156                    b_u,
1157                    xi[row],
1158                );
1159            coeff_eta[row] = eta_eta;
1160            coeff_etaw_b[row] = eta_w_b;
1161            coeff_etaw_d1[row] = eta_w_d1;
1162            coeff_etaw_d2[row] = eta_w_d2;
1163            coeff_ww_bb[row] = ww_bb;
1164            coeff_ww_db[row] = ww_db;
1165        }
1166
1167        let d_h_eta_eta = xt_diag_x_dense(&x_eta, &coeff_eta)?;
1168        let d_h_eta_w = xt_diag_y_dense(&x_eta, &coeff_etaw_b, &geom.basis)?
1169            + &xt_diag_y_dense(&x_eta, &coeff_etaw_d1, &geom.basis_d1)?
1170            + &xt_diag_y_dense(&x_eta, &coeff_etaw_d2, &geom.basis_d2)?;
1171        let a_ww = xt_diag_y_dense(&geom.basis_d1, &coeff_ww_db, &geom.basis)?;
1172        let d_h_ww = xt_diag_x_dense(&geom.basis, &coeff_ww_bb)? + &a_ww + a_ww.t();
1173        Ok(Some(binomial_pack_mean_wiggle_joint_symmetrichessian(
1174            &d_h_eta_eta,
1175            &d_h_eta_w,
1176            &d_h_ww,
1177        )))
1178    }
1179
1180    /// Exact second-order directional derivative D²H\[u,v\] of the joint Hessian
1181    /// for the BinomialMeanWiggle two-block model (eta, wiggle).
1182    ///
1183    /// # Mathematical derivation
1184    ///
1185    /// The negative log-likelihood Hessian element for indices (a, b) in the
1186    /// joint coefficient vector is:
1187    ///
1188    ///   H_ab = m2 * q_a * q_b + m1 * q_ab
1189    ///
1190    /// where m_k = d^k F / dq^k (k-th derivative of the negative log-likelihood
1191    /// w.r.t. the effective predictor q), q_a = dq/d(beta_a), and q_ab =
1192    /// d²q/(d(beta_a) d(beta_b)).
1193    ///
1194    /// The effective predictor is q = q0 + w(q0) where q0 = X_eta * beta_eta
1195    /// and w(q0) = B(q0) * beta_w is the link wiggle.  Write:
1196    ///   a = dq/dq0 = 1 + B'·beta_w       (geometry first derivative)
1197    ///   b = d²q/dq0² = B''·beta_w         (geometry second derivative)
1198    ///   c = d³q/dq0³ = B'''·beta_w        (geometry third derivative)
1199    ///   d = d⁴q/dq0⁴ = B''''·beta_w       (geometry fourth derivative)
1200    ///
1201    /// For a perturbation direction u = (u_eta, u_w), the chain-rule
1202    /// perturbations are:
1203    ///   q_u   = a·xi_u + phi_u             (first-order predictor perturbation)
1204    ///   a_u   = b·xi_u + basis1_u          (perturbation of geometry factor a)
1205    ///   b_u   = c·xi_u + basis2_u          (perturbation of geometry factor b)
1206    ///   c_u   = d·xi_u + basis3_u          (perturbation of geometry factor c)
1207    ///
1208    /// where xi_u = X_eta·u_eta, phi_u = B·u_w, basis_k_u = B^(k)·u_w.
1209    ///
1210    /// Mixed second-order perturbations (u,v) are:
1211    ///   q_uv  = b·xi_u·xi_v + basis1_u·xi_v + basis1_v·xi_u
1212    ///   a_uv  = c·xi_u·xi_v + basis2_u·xi_v + basis2_v·xi_u
1213    ///   b_uv  = d·xi_u·xi_v + basis3_u·xi_v + basis3_v·xi_u
1214    ///
1215    /// ## Block decomposition
1216    ///
1217    /// **eta-eta block** (X_eta' diag(coeff) X_eta):
1218    ///   The Hessian element for eta indices (i,j) factors as
1219    ///     H(eta_i, eta_j) = [m2·a² + m1·b] · x_eta(i)·x_eta(j)
1220    ///   so D²H_eta_eta\[u,v\] = X_eta' diag(coeff_eta) X_eta
1221    ///   where `coeff_eta` is the eta-eta output of the shared order-four
1222    ///   curve/wiggle channel bundle with q_a=a, q_b=a, q_ab=b and their
1223    ///   chain-rule perturbations.
1224    ///
1225    /// **eta-w block** (X_eta' diag(...) [B, B', B'', B''']):
1226    ///   The static Hessian is:
1227    ///     H(eta_i, w_j) = (m2·a)·x_eta(i)·B_j + m1·x_eta(i)·B'_j
1228    ///   Taking D²\[u,v\] requires differentiating both the scalar coefficients
1229    ///   (m2·a, m1) and the basis matrices (B, B' depend on q0 via the chain
1230    ///   rule dB_j/du = B'_j·xi_u).  The full product rule gives four basis-matrix
1231    ///   tiers: B, B', B'', B'''.
1232    ///
1233    /// **w-w block** (B' diag(...) B, etc.):
1234    ///   The static Hessian is H(w_i, w_j) = m2·B_i·B_j.
1235    ///   D²\[u,v\] expands via the product rule on m2, B_i, B_j, each of which
1236    ///   depends on beta through q and q0.  This gives terms involving
1237    ///   B·B, B'·B, B'·B', and B''·B (all symmetrised).
1238    fn exact_newton_joint_hessian_second_directional_derivative_with_specs(
1239        &self,
1240        block_states: &[ParameterBlockState],
1241        specs: &[ParameterBlockSpec],
1242        d_beta_u_flat: &Array1<f64>,
1243        d_beta_v_flat: &Array1<f64>,
1244    ) -> Result<Option<Array2<f64>>, String> {
1245        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
1246        let x_eta = self.dense_eta_design_fromspecs(specs)?;
1247        let eta = &block_states[Self::BLOCK_ETA].eta;
1248        let etaw = &block_states[Self::BLOCK_WIGGLE].eta;
1249        let betaw = &block_states[Self::BLOCK_WIGGLE].beta;
1250        let n = self.y.len();
1251        if eta.len() != n || etaw.len() != n || self.weights.len() != n {
1252            return Err(GamlssError::DimensionMismatch {
1253                reason: "BinomialMeanWiggleFamily input size mismatch".to_string(),
1254            }
1255            .into());
1256        }
1257        let geom = self.wiggle_geometry(eta.view(), betaw.view())?;
1258        let p_eta = x_eta.ncols();
1259        let pw = geom.basis.ncols();
1260        let total = p_eta + pw;
1261        if d_beta_u_flat.len() != total || d_beta_v_flat.len() != total {
1262            return Err(GamlssError::DimensionMismatch { reason: format!(
1263                "BinomialMeanWiggleFamily joint second d_beta length mismatch: got {} and {}, expected {}",
1264                d_beta_u_flat.len(),
1265                d_beta_v_flat.len(),
1266                total
1267            ) }.into());
1268        }
1269
1270        // Split directions into eta and wiggle components.
1271        let u_eta = d_beta_u_flat.slice(s![0..p_eta]).to_owned();
1272        let v_eta = d_beta_v_flat.slice(s![0..p_eta]).to_owned();
1273        let uw = d_beta_u_flat.slice(s![p_eta..total]).to_owned();
1274        let vw = d_beta_v_flat.slice(s![p_eta..total]).to_owned();
1275
1276        // Per-row linear-predictor perturbations from each direction.
1277        let xi_u = x_eta.dot(&u_eta); // eta perturbation in direction u
1278        let xi_v = x_eta.dot(&v_eta); // eta perturbation in direction v
1279        let phi_u = geom.basis.dot(&uw); // direct wiggle basis, direction u
1280        let phi_v = geom.basis.dot(&vw); // direct wiggle basis, direction v
1281        let b1u = geom.basis_d1.dot(&uw); // first-derivative basis, direction u
1282        let b1v = geom.basis_d1.dot(&vw);
1283        let b2u = geom.basis_d2.dot(&uw); // second-derivative basis, direction u
1284        let b2v = geom.basis_d2.dot(&vw);
1285        let b3u = geom.basis_d3.dot(&uw); // third-derivative basis, direction u
1286        let b3v = geom.basis_d3.dot(&vw);
1287
1288        // Per-row chain-rule perturbations of q, a = dq/dq0, b = d²q/dq0²:
1289        //   q_u = a·xi_u + phi_u
1290        //   a_u = b·xi_u + basis1_u
1291        //   b_u = c·xi_u + basis2_u
1292        //   c_u = d·xi_u + basis3_u
1293        // Mixed second-order perturbations:
1294        //   q_uv = b·xi_u·xi_v + basis1_u·xi_v + basis1_v·xi_u
1295        //   a_uv = c·xi_u·xi_v + basis2_u·xi_v + basis2_v·xi_u
1296        //   b_uv = d·xi_u·xi_v + basis3_u·xi_v + basis3_v·xi_u
1297
1298        // Scaled basis matrices for the cross-product terms in the w-w and eta-w
1299        // blocks (same pattern as GaussianLocationScaleWiggleFamily).
1300        let basis_u = scale_matrix_rows(&geom.basis_d1, &xi_u)?; // dB/du = B'·xi_u
1301        let basis_v = scale_matrix_rows(&geom.basis_d1, &xi_v)?; // dB/dv = B'·xi_v
1302        let basis_uv = scale_matrix_rows(&geom.basis_d2, &(&xi_u * &xi_v))?; // d²B/dudv = B''·xi_u·xi_v
1303        // Per-row coefficient arrays for assembling the block-matrix products.
1304        // All nine mathematical tiers below are projections of the same
1305        // F(q(theta)) jet and are lowered together by the universal
1306        // Faà-di-Bruno bundle in the row loop.
1307        let mut coeff_eta = Array1::<f64>::zeros(n);
1308
1309        // Coefficients for the eta-w block: X_eta' diag(c_*) M where M ∈ {B, B', B'', B'''}
1310        //
1311        // The static cross-Hessian is:
1312        //   H(eta_i, w_j) = (m2·a)·x_i·B_j + m1·x_i·B'_j
1313        // where B_j and B'_j are row evaluations of basis column j.
1314        //
1315        // Write C_B = m2·a (scalar coefficient multiplying B in the cross block)
1316        // and   C_B1 = m1  (scalar coefficient multiplying B' in the cross block).
1317        //
1318        // Product rule on C_B·B:
1319        //   d(C_B·B)/du = (dC_B/du)·B + C_B·B'·xi_u
1320        //   d²(C_B·B)/dudv = (d²C_B/dudv)·B + (dC_B/du)·B'·xi_v
1321        //                   + (dC_B/dv)·B'·xi_u + C_B·B''·xi_u·xi_v
1322        //
1323        // Product rule on C_B1·B':
1324        //   d²(C_B1·B')/dudv = (d²C_B1/dudv)·B' + (dC_B1/du)·B''·xi_v
1325        //                     + (dC_B1/dv)·B''·xi_u + C_B1·B'''·xi_u·xi_v
1326        //
1327        // Derivatives of the scalar coefficients:
1328        //   C_B  = m2·a
1329        //   dC_B/du  = m3·q_u·a + m2·a_u
1330        //   dC_B/dv  = m3·q_v·a + m2·a_v
1331        //   d²C_B/dudv = m4·q_u·q_v·a + m3·(q_uv·a + q_u·a_v + q_v·a_u) + m2·a_uv
1332        //
1333        //   C_B1 = m1
1334        //   dC_B1/du = m2·q_u
1335        //   dC_B1/dv = m2·q_v
1336        //   d²C_B1/dudv = m3·q_u·q_v + m2·q_uv
1337        //
1338        // Grouping by basis-matrix tier:
1339        //   B:   d²C_B/dudv
1340        //   B':  (dC_B/du)·xi_v + (dC_B/dv)·xi_u + d²C_B1/dudv
1341        //   B'': C_B·xi_u·xi_v + (dC_B1/du)·xi_v + (dC_B1/dv)·xi_u
1342        //   B''': C_B1·xi_u·xi_v
1343        let mut coeff_etaw_b = Array1::<f64>::zeros(n);
1344        let mut coeff_etaw_d1 = Array1::<f64>::zeros(n);
1345        let mut coeff_etaw_d2 = Array1::<f64>::zeros(n);
1346        let mut coeff_etaw_d3 = Array1::<f64>::zeros(n);
1347
1348        // Coefficients for the w-w block.
1349        //
1350        // The static w-w Hessian is:
1351        //   H(w_i, w_j) = m2·B_i·B_j
1352        //
1353        // Note: there is no m1·q_ij term because d²q/(d(beta_w_i) d(beta_w_j)) = 0
1354        // (the basis vectors B_i enter q linearly in beta_w).
1355        //
1356        // Product rule on m2·B_i·B_j, treating each factor as depending on beta:
1357        //   d²(m2·B_i·B_j)/dudv
1358        //     = (d²m2/dudv)·B_i·B_j                        → B'diag B  (symmetrised)
1359        //     + (dm2/du)·(B'_i·xi_v·B_j + B_i·B'_j·xi_v)  → dw_u terms
1360        //     + (dm2/dv)·(B'_i·xi_u·B_j + B_i·B'_j·xi_u)  → dw_v terms
1361        //     + m2·(B''_i·xi_u·xi_v·B_j + B'_i·xi_u·B'_j·xi_v
1362        //          + B'_i·xi_v·B'_j·xi_u + B_i·B''_j·xi_u·xi_v)
1363        //
1364        // where dm2/du = m3·q_u, dm2/dv = m3·q_v, d²m2/dudv = m4·q_u·q_v + m3·q_uv.
1365        //
1366        // Following the Gaussian LS wiggle pattern, we express this via:
1367        //   xt_diag_x_dense(B, dw_uv)                    — coeff: d²m2
1368        //   xt_diag_y_dense(basis_u, dw_v, B) + transpose — dB/du weighted by dm2/dv
1369        //   xt_diag_y_dense(basis_v, dw_u, B) + transpose — dB/dv weighted by dm2/du
1370        //   xt_diag_y_dense(basis_uv, w, B) + transpose   — d²B/dudv weighted by m2
1371        //   xt_diag_y_dense(basis_u, w, basis_v) + transpose — dB/du·dB/dv weighted by m2
1372        let mut dw = Array1::<f64>::zeros(n);
1373        let mut dw_u = Array1::<f64>::zeros(n);
1374        let mut dw_v = Array1::<f64>::zeros(n);
1375        let mut dw_uv = Array1::<f64>::zeros(n);
1376
1377        for row in 0..n {
1378            let q = eta[row] + etaw[row];
1379            let (m1, m2, m3) = self.neglog_q_derivatives(self.y[row], self.weights[row], q)?;
1380            let m4 = self.neglog_q_fourth_derivative(self.y[row], self.weights[row], q)?;
1381            let a = geom.dq_dq0[row];
1382            let b = geom.d2q_dq02[row];
1383            let c = geom.d3q_dq03[row];
1384            let d = geom.d4q_dq04[row];
1385
1386            // Chain-rule perturbations in direction u.
1387            let q_u = a * xi_u[row] + phi_u[row];
1388            let a_u = b * xi_u[row] + b1u[row];
1389            let b_u = c * xi_u[row] + b2u[row];
1390
1391            // Chain-rule perturbations in direction v.
1392            let q_v = a * xi_v[row] + phi_v[row];
1393            let a_v = b * xi_v[row] + b1v[row];
1394            let b_v = c * xi_v[row] + b2v[row];
1395
1396            // Mixed second-order perturbations.
1397            let q_uv = b * xi_u[row] * xi_v[row] + b1u[row] * xi_v[row] + b1v[row] * xi_u[row];
1398            let a_uv = c * xi_u[row] * xi_v[row] + b2u[row] * xi_v[row] + b2v[row] * xi_u[row];
1399            let b_uv = d * xi_u[row] * xi_v[row] + b3u[row] * xi_v[row] + b3v[row] * xi_u[row];
1400
1401            let [eta_eta, eta_w_b, eta_w_d1, eta_w_d2, eta_w_d3, _, _, _, _] =
1402                mean_wiggle_second_directional_coefficients(
1403                    [m1, m2, m3, m4],
1404                    [q_u, q_v, q_uv],
1405                    [a, a_u, a_v, a_uv],
1406                    [b, b_u, b_v, b_uv],
1407                    [xi_u[row], xi_v[row]],
1408                );
1409            coeff_eta[row] = eta_eta;
1410            coeff_etaw_b[row] = eta_w_b;
1411            coeff_etaw_d1[row] = eta_w_d1;
1412            coeff_etaw_d2[row] = eta_w_d2;
1413            coeff_etaw_d3[row] = eta_w_d3;
1414
1415            // ── w-w block coefficients ──
1416            // The w-w static Hessian coefficient is m2 (for B'diag B).
1417            dw[row] = m2;
1418            dw_u[row] = m3 * q_u;
1419            dw_v[row] = m3 * q_v;
1420            dw_uv[row] = m4 * q_u * q_v + m3 * q_uv;
1421        }
1422
1423        // ── Assemble eta-eta block ──
1424        let d2_h_eta_eta = xt_diag_x_dense(&x_eta, &coeff_eta)?;
1425
1426        // ── Assemble eta-w block ──
1427        // The second-order directional derivative of the cross block H_eta_w is:
1428        //   d²H_eta_w[u,v] = X_eta' diag(coeff_etaw_b)  B
1429        //                   + X_eta' diag(coeff_etaw_d1) B'
1430        //                   + X_eta' diag(coeff_etaw_d2) B''
1431        //                   + X_eta' diag(coeff_etaw_d3) B'''
1432        let d2_h_eta_w = xt_diag_y_dense(&x_eta, &coeff_etaw_b, &geom.basis)?
1433            + &xt_diag_y_dense(&x_eta, &coeff_etaw_d1, &geom.basis_d1)?
1434            + &xt_diag_y_dense(&x_eta, &coeff_etaw_d2, &geom.basis_d2)?
1435            + &xt_diag_y_dense(&x_eta, &coeff_etaw_d3, &geom.basis_d3)?;
1436
1437        // ── Assemble w-w block ──
1438        // Following the Gaussian LS wiggle pattern (lines 6351-6363), the w-w
1439        // second directional derivative is assembled from scaled basis products:
1440        //
1441        //   d²(m2·B_i·B_j)/dudv decomposition:
1442        //     (d²m2)     · B_i·B_j        → xt_diag_x(B, dw_uv)
1443        //     (dm2/du)   · dB_j/dv · B_i  → xt_diag_y(basis_v, dw_u, B) + transpose
1444        //     (dm2/dv)   · dB_j/du · B_i  → xt_diag_y(basis_u, dw_v, B) + transpose
1445        //     m2 · d²B_j/dudv · B_i       → xt_diag_y(basis_uv, dw, B) + transpose
1446        //     m2 · dB_i/du · dB_j/dv      → xt_diag_y(basis_u, dw, basis_v) + transpose
1447        let a_ab = xt_diag_y_dense(&basis_uv, &dw, &geom.basis)?;
1448        let a_ij = xt_diag_y_dense(&basis_u, &dw, &basis_v)?;
1449        let a_iwj = xt_diag_y_dense(&basis_u, &dw_v, &geom.basis)?;
1450        let a_jwi = xt_diag_y_dense(&basis_v, &dw_u, &geom.basis)?;
1451        let d2_h_ww = &a_ab
1452            + &a_ab.t()
1453            + &a_ij
1454            + a_ij.t()
1455            + &a_iwj
1456            + a_iwj.t()
1457            + &a_jwi
1458            + a_jwi.t()
1459            + &xt_diag_x_dense(&geom.basis, &dw_uv)?;
1460
1461        Ok(Some(binomial_pack_mean_wiggle_joint_symmetrichessian(
1462            &d2_h_eta_eta,
1463            &d2_h_eta_w,
1464            &d2_h_ww,
1465        )))
1466    }
1467
1468    fn exact_newton_joint_psi_terms(
1469        &self,
1470        block_states: &[ParameterBlockState],
1471        specs: &[ParameterBlockSpec],
1472        hyper_layout: &crate::custom_family::CustomFamilyHyperLayout,
1473        psi_index: usize,
1474    ) -> Result<Option<gam_problem::ExactNewtonJointPsiTerms>, String> {
1475        if hyper_layout.family_axis_count() != 0 {
1476            return Err("BinomialMeanWiggleFamily does not declare family-owned hyper axes"
1477                .to_string());
1478        }
1479        let derivative_blocks = hyper_layout.design_derivative_blocks();
1480        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
1481        if derivative_blocks.len() != 2 {
1482            return Err(GamlssError::DimensionMismatch { reason: format!(
1483                "BinomialMeanWiggleFamily joint psi terms expect 2 derivative block lists, got {}",
1484                derivative_blocks.len()
1485            ) }.into());
1486        }
1487        let x_eta = self.dense_eta_design_fromspecs(specs)?;
1488        let eta = &block_states[Self::BLOCK_ETA].eta;
1489        let etaw = &block_states[Self::BLOCK_WIGGLE].eta;
1490        let betaw = &block_states[Self::BLOCK_WIGGLE].beta;
1491        let n = self.y.len();
1492        if eta.len() != n || etaw.len() != n || self.weights.len() != n {
1493            return Err(GamlssError::DimensionMismatch {
1494                reason: "BinomialMeanWiggleFamily input size mismatch".to_string(),
1495            }
1496            .into());
1497        }
1498        let geom = self.wiggle_geometry(eta.view(), betaw.view())?;
1499        let p_eta = x_eta.ncols();
1500        let pw = geom.basis.ncols();
1501        let implicit_dir =
1502            self.exact_newton_joint_psi_action(block_states, derivative_blocks, psi_index, p_eta)?;
1503        let dense_dir = if implicit_dir.is_none() {
1504            self.exact_newton_joint_psi_direction(
1505                block_states,
1506                derivative_blocks,
1507                psi_index,
1508                &x_eta,
1509            )?
1510        } else {
1511            None
1512        };
1513        let z_eta_psi = if let Some((_, ref z_eta_psi)) = implicit_dir {
1514            z_eta_psi
1515        } else if let Some(ref dir_a) = dense_dir {
1516            &dir_a.z_eta_psi
1517        } else {
1518            return Ok(None);
1519        };
1520
1521        let mut objective_psi = 0.0;
1522        let mut score_eta_xa = Array1::<f64>::zeros(n);
1523        let mut score_eta_x = Array1::<f64>::zeros(n);
1524        let mut score_w_b = Array1::<f64>::zeros(n);
1525        let mut score_w_d1 = Array1::<f64>::zeros(n);
1526
1527        let mut coeff_eta_eta_xx = Array1::<f64>::zeros(n);
1528        let mut coeff_eta_eta_xa_x = Array1::<f64>::zeros(n);
1529        let mut coeff_eta_w_xa_b = Array1::<f64>::zeros(n);
1530        let mut coeff_eta_w_x_b = Array1::<f64>::zeros(n);
1531        let mut coeff_eta_w_x_d1 = Array1::<f64>::zeros(n);
1532        let mut coeff_eta_w_xa_d1 = Array1::<f64>::zeros(n);
1533        let mut coeff_eta_w_x_d2 = Array1::<f64>::zeros(n);
1534        let mut coeff_ww_bb = Array1::<f64>::zeros(n);
1535        let mut coeff_ww_db = Array1::<f64>::zeros(n);
1536
1537        for row in 0..n {
1538            let q = eta[row] + etaw[row];
1539            let (m1, m2, m3) = self.neglog_q_derivatives(self.y[row], self.weights[row], q)?;
1540            let z_a = z_eta_psi[row];
1541            let a = geom.dq_dq0[row];
1542            let b = geom.d2q_dq02[row];
1543            let c = geom.d3q_dq03[row];
1544            let q_a = a * z_a;
1545
1546            objective_psi += m1 * q_a;
1547
1548            score_eta_xa[row] = m1 * a;
1549            score_eta_x[row] = m2 * q_a * a + m1 * b * z_a;
1550            score_w_b[row] = m2 * q_a;
1551            score_w_d1[row] = m1 * z_a;
1552
1553            coeff_eta_eta_xx[row] =
1554                m3 * q_a * a * a + m2 * (2.0 * a * b * z_a + q_a * b) + m1 * c * z_a;
1555            coeff_eta_eta_xa_x[row] = m2 * a * a + m1 * b;
1556            coeff_eta_w_xa_b[row] = m2 * a;
1557            coeff_eta_w_x_b[row] = m3 * q_a * a + m2 * b * z_a;
1558            coeff_eta_w_x_d1[row] = m2 * (a * z_a + q_a);
1559            coeff_eta_w_xa_d1[row] = m1;
1560            coeff_eta_w_x_d2[row] = m1 * z_a;
1561            coeff_ww_bb[row] = m3 * q_a;
1562            coeff_ww_db[row] = m2 * z_a;
1563        }
1564
1565        let score_w = gam_linalg::faer_ndarray::fast_atv(&geom.basis, &score_w_b)
1566            + gam_linalg::faer_ndarray::fast_atv(&geom.basis_d1, &score_w_d1);
1567
1568        if let Some((action, _)) = implicit_dir {
1569            let score_eta = action.transpose_mul(score_eta_xa.view())
1570                + gam_linalg::faer_ndarray::fast_atv(x_eta.as_ref(), &score_eta_x);
1571            let score_psi = binomial_pack_mean_wiggle_joint_score(&score_eta, &score_w);
1572            let x_eta_arc = shared_dense_arc(x_eta.as_ref());
1573            let basis_arc = Arc::new(geom.basis.clone());
1574            let basis_d1_arc = Arc::new(geom.basis_d1.clone());
1575            let basis_d2_arc = Arc::new(geom.basis_d2.clone());
1576            let zeros = Array1::<f64>::zeros(n);
1577            let operator = CustomFamilyJointPsiOperator::new(
1578                p_eta + pw,
1579                vec![
1580                    CustomFamilyJointDesignChannel::new(
1581                        0..p_eta,
1582                        Arc::clone(&x_eta_arc),
1583                        Some(action),
1584                    ),
1585                    CustomFamilyJointDesignChannel::new(
1586                        p_eta..p_eta + pw,
1587                        Arc::clone(&basis_arc),
1588                        None,
1589                    ),
1590                    CustomFamilyJointDesignChannel::new(
1591                        p_eta..p_eta + pw,
1592                        Arc::clone(&basis_d1_arc),
1593                        None,
1594                    ),
1595                    CustomFamilyJointDesignChannel::new(
1596                        p_eta..p_eta + pw,
1597                        Arc::clone(&basis_d2_arc),
1598                        None,
1599                    ),
1600                ],
1601                vec![
1602                    CustomFamilyJointDesignPairContribution::new(
1603                        0,
1604                        0,
1605                        coeff_eta_eta_xa_x.clone(),
1606                        coeff_eta_eta_xx.clone(),
1607                    ),
1608                    CustomFamilyJointDesignPairContribution::new(
1609                        0,
1610                        1,
1611                        coeff_eta_w_xa_b.clone(),
1612                        coeff_eta_w_x_b.clone(),
1613                    ),
1614                    CustomFamilyJointDesignPairContribution::new(
1615                        1,
1616                        0,
1617                        coeff_eta_w_xa_b.clone(),
1618                        coeff_eta_w_x_b.clone(),
1619                    ),
1620                    CustomFamilyJointDesignPairContribution::new(
1621                        0,
1622                        2,
1623                        coeff_eta_w_xa_d1.clone(),
1624                        coeff_eta_w_x_d1.clone(),
1625                    ),
1626                    CustomFamilyJointDesignPairContribution::new(
1627                        2,
1628                        0,
1629                        coeff_eta_w_xa_d1.clone(),
1630                        coeff_eta_w_x_d1.clone(),
1631                    ),
1632                    CustomFamilyJointDesignPairContribution::new(
1633                        0,
1634                        3,
1635                        zeros.clone(),
1636                        coeff_eta_w_x_d2.clone(),
1637                    ),
1638                    CustomFamilyJointDesignPairContribution::new(
1639                        3,
1640                        0,
1641                        zeros.clone(),
1642                        coeff_eta_w_x_d2.clone(),
1643                    ),
1644                    CustomFamilyJointDesignPairContribution::new(
1645                        1,
1646                        1,
1647                        zeros.clone(),
1648                        coeff_ww_bb.clone(),
1649                    ),
1650                    CustomFamilyJointDesignPairContribution::new(
1651                        2,
1652                        1,
1653                        zeros.clone(),
1654                        coeff_ww_db.clone(),
1655                    ),
1656                    CustomFamilyJointDesignPairContribution::new(1, 2, zeros, coeff_ww_db.clone()),
1657                ],
1658            );
1659            return Ok(Some(gam_problem::ExactNewtonJointPsiTerms {
1660                objective_psi,
1661                score_psi,
1662                hessian_psi: Array2::zeros((0, 0)),
1663                hessian_psi_operator: Some(std::sync::Arc::new(operator)),
1664            }));
1665        }
1666
1667        let dir_a =
1668            dense_dir.expect("dense psi direction should exist when implicit direction is absent");
1669        let x_eta_psi = dir_a
1670            .x_eta_psi
1671            .as_ref()
1672            .expect("dense eta psi design should exist when implicit direction is absent");
1673        let score_psi = binomial_pack_mean_wiggle_joint_score(
1674            &(gam_linalg::faer_ndarray::fast_atv(x_eta_psi, &score_eta_xa)
1675                + gam_linalg::faer_ndarray::fast_atv(x_eta.as_ref(), &score_eta_x)),
1676            &score_w,
1677        );
1678        let a_eta_eta = xt_diag_y_dense(x_eta_psi, &coeff_eta_eta_xa_x, &x_eta)?;
1679        let h_eta_eta = &a_eta_eta + &a_eta_eta.t() + &xt_diag_x_dense(&x_eta, &coeff_eta_eta_xx)?;
1680        let h_eta_w = xt_diag_y_dense(x_eta_psi, &coeff_eta_w_xa_b, &geom.basis)?
1681            + &xt_diag_y_dense(&x_eta, &coeff_eta_w_x_b, &geom.basis)?
1682            + &xt_diag_y_dense(&x_eta, &coeff_eta_w_x_d1, &geom.basis_d1)?
1683            + &xt_diag_y_dense(x_eta_psi, &coeff_eta_w_xa_d1, &geom.basis_d1)?
1684            + &xt_diag_y_dense(&x_eta, &coeff_eta_w_x_d2, &geom.basis_d2)?;
1685        let a_ww = xt_diag_y_dense(&geom.basis_d1, &coeff_ww_db, &geom.basis)?;
1686        let h_ww = xt_diag_x_dense(&geom.basis, &coeff_ww_bb)? + &a_ww + a_ww.t();
1687
1688        Ok(Some(gam_problem::ExactNewtonJointPsiTerms {
1689            objective_psi,
1690            score_psi,
1691            hessian_psi: binomial_pack_mean_wiggle_joint_symmetrichessian(
1692                &h_eta_eta, &h_eta_w, &h_ww,
1693            ),
1694            hessian_psi_operator: None,
1695        }))
1696    }
1697}
1698
1699pub(crate) struct BinomialMeanWiggleHessianWorkspace {
1700    pub(crate) family: BinomialMeanWiggleFamily,
1701    pub(crate) block_states: Vec<ParameterBlockState>,
1702    /// The block specs the workspace was built against. Held so the workspace
1703    /// can answer `joint_gradient_evaluation` from its own captured state; the
1704    /// designs are `Arc`-backed, so this clone is O(1) in the matrix data.
1705    pub(crate) specs: Vec<ParameterBlockSpec>,
1706    pub(crate) x_eta: Arc<Array2<f64>>,
1707    pub(crate) hessian_operator: Arc<RowCoeffOperator>,
1708}
1709
1710impl BinomialMeanWiggleHessianWorkspace {
1711    pub(crate) fn new(
1712        family: BinomialMeanWiggleFamily,
1713        block_states: Vec<ParameterBlockState>,
1714        specs: Vec<ParameterBlockSpec>,
1715        x_eta: Array2<f64>,
1716    ) -> Result<Self, String> {
1717        let x_eta = Arc::new(x_eta);
1718        let hessian_operator = family.bmw_static_hessian_operator(&block_states, x_eta.clone())?;
1719        Ok(Self {
1720            family,
1721            block_states,
1722            specs,
1723            x_eta,
1724            hessian_operator,
1725        })
1726    }
1727}
1728
1729impl ExactNewtonJointHessianWorkspace for BinomialMeanWiggleHessianWorkspace {
1730    /// The terminal constrained-posterior assembly places the ambient centre at
1731    /// `beta_unc = beta_hat - Sigma * grad(l_p)`, so it needs the exact
1732    /// likelihood score at the certified mode. This family answers
1733    /// `exact_newton_joint_gradient_evaluation` analytically, which makes the
1734    /// inner solve skip the `FamilyEvaluation` whose working sets would
1735    /// otherwise carry that score forward, leaving this workspace as the sole
1736    /// retained source at assembly. Without this method it exposes none and a
1737    /// converged constrained fit is refused outright (#2474).
1738    ///
1739    /// Reporting it here does not re-evaluate a live family: the workspace the
1740    /// assembly holds is built at the converged states (fresh, by the
1741    /// returned-mode curvature certificate) and owns its own family clone, and
1742    /// the delegate is the same working-set identity the inner joint-Newton RHS
1743    /// uses, so the score reported is the one the mode was certified with.
1744    fn joint_gradient_evaluation(
1745        &self,
1746    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
1747        self.family
1748            .exact_newton_joint_gradient_evaluation(&self.block_states, &self.specs)
1749    }
1750
1751    fn warm_up_outer_caches_for_mode(
1752        &self,
1753        eval_mode: gam_problem::EvalMode,
1754    ) -> Result<(), String> {
1755        match eval_mode {
1756            gam_problem::EvalMode::ValueOnly
1757            | gam_problem::EvalMode::ValueAndGradient
1758            | gam_problem::EvalMode::ValueGradientHessian => Ok(()),
1759        }
1760    }
1761
1762    fn hessian_matvec_available(&self) -> bool {
1763        true
1764    }
1765
1766    fn hessian_matvec(&self, v: &Array1<f64>) -> Result<Option<Array1<f64>>, String> {
1767        Ok(Some(gam_problem::HyperOperator::mul_vec(
1768            self.hessian_operator.as_ref(),
1769            v,
1770        )))
1771    }
1772
1773    fn hessian_diagonal(&self) -> Result<Option<Array1<f64>>, String> {
1774        // The source resolver requires a finite diagonal alongside the HVP to
1775        // build an operator curvature source; `None` here made every
1776        // joint-workspace fit of this family die at the inner-solve boundary
1777        // with "supplied no inner-solve curvature source" (#2299 link-wiggle
1778        // gate). The static operator's diagonal is exact and O(n·(p_η+p_w)).
1779        Ok(Some(self.hessian_operator.diagonal()))
1780    }
1781
1782    fn directional_derivative(
1783        &self,
1784        d_beta_flat: &Array1<f64>,
1785    ) -> Result<Option<Array2<f64>>, String> {
1786        Ok(self
1787            .directional_derivative_operator(d_beta_flat)?
1788            .map(|operator| operator.to_dense()))
1789    }
1790
1791    fn directional_derivative_operator(
1792        &self,
1793        d_beta_flat: &Array1<f64>,
1794    ) -> Result<Option<Arc<dyn gam_problem::HyperOperator>>, String> {
1795        self.family
1796            .bmw_directional_operator(&self.block_states, self.x_eta.clone(), d_beta_flat)
1797    }
1798
1799    fn second_directional_derivative(
1800        &self,
1801        d_beta_u_flat: &Array1<f64>,
1802        d_beta_v_flat: &Array1<f64>,
1803    ) -> Result<Option<Array2<f64>>, String> {
1804        Ok(self
1805            .second_directional_derivative_operator(d_beta_u_flat, d_beta_v_flat)?
1806            .map(|operator| operator.to_dense()))
1807    }
1808
1809    fn second_directional_derivative_operator(
1810        &self,
1811        d_beta_u: &Array1<f64>,
1812        d_beta_v: &Array1<f64>,
1813    ) -> Result<Option<Arc<dyn gam_problem::HyperOperator>>, String> {
1814        self.family.bmw_second_directional_operator(
1815            &self.block_states,
1816            self.x_eta.clone(),
1817            d_beta_u,
1818            d_beta_v,
1819        )
1820    }
1821}
1822
1823impl CustomFamilyGenerative for BinomialMeanWiggleFamily {
1824    fn generativespec(
1825        &self,
1826        block_states: &[ParameterBlockState],
1827    ) -> Result<GenerativeSpec, String> {
1828        validate_block_count::<GamlssError>("BinomialMeanWiggleFamily", 2, block_states.len())?;
1829        let eta = &block_states[Self::BLOCK_ETA].eta;
1830        let etaw = &block_states[Self::BLOCK_WIGGLE].eta;
1831        if eta.len() != self.y.len() || etaw.len() != self.y.len() {
1832            return Err(GamlssError::DimensionMismatch {
1833                reason: "BinomialMeanWiggleFamily generative size mismatch".to_string(),
1834            }
1835            .into());
1836        }
1837        let mean = gamlss_rowwise_map_result(self.y.len(), |i| {
1838            let jet = inverse_link_jet_for_inverse_link(&self.link_kind, eta[i] + etaw[i])
1839                .map_err(|e| format!("fixed-link wiggle inverse-link evaluation failed: {e}"))?;
1840            Ok(jet.mu)
1841        })?;
1842        Ok(GenerativeSpec {
1843            mean,
1844            noise: NoiseModel::Bernoulli,
1845        })
1846    }
1847}
1848
1849#[cfg(test)]
1850mod exact_frozen_monotonicity_tests {
1851    use super::*;
1852
1853    fn frozen_family_and_wiggle_spec() -> (BinomialMeanWiggleFamily, ParameterBlockSpec) {
1854        let n = 4;
1855        let p = 3;
1856        let frozen = Array2::<f64>::zeros((n, p));
1857        let family = BinomialMeanWiggleFamily {
1858            y: Array1::zeros(n),
1859            weights: Array1::ones(n),
1860            link_kind: InverseLink::Standard(StandardLink::Logit),
1861            wiggle_knots: Array1::linspace(-1.0, 1.0, 8),
1862            wiggle_degree: 3,
1863            policy: gam_runtime::resource::ResourcePolicy::default_library(),
1864            frozen_warp_design: Some(Arc::new(frozen.clone())),
1865        };
1866        let spec = ParameterBlockSpec {
1867            name: "wiggle".to_string(),
1868            design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(frozen)),
1869            offset: Array1::zeros(n),
1870            penalties: vec![],
1871            nullspace_dims: vec![],
1872            initial_log_lambdas: Array1::zeros(0),
1873            initial_beta: None,
1874            gauge_priority: 100,
1875            jacobian_callback: None,
1876            stacked_design: None,
1877            stacked_offset: None,
1878        };
1879        (family, spec)
1880    }
1881
1882    #[test]
1883    fn frozen_warp_keeps_exact_nonnegative_i_spline_cone() {
1884        let (family, spec) = frozen_family_and_wiggle_spec();
1885        let constraints = family
1886            .block_linear_constraints(&[], BinomialMeanWiggleFamily::BLOCK_WIGGLE, &spec)
1887            .expect("frozen constraint construction")
1888            .expect("wiggle block must be constrained")
1889            .to_dense()
1890            .expect("wiggle cone is a small dense system");
1891        assert_eq!(constraints.a, Array2::<f64>::eye(3));
1892        assert_eq!(constraints.b, Array1::<f64>::zeros(3));
1893
1894        let solver_slop = Array1::from_vec(vec![
1895            -0.5 * crate::wiggle::MONOTONE_WIGGLE_ACTIVE_SET_TOL,
1896            0.2,
1897            0.0,
1898        ]);
1899        let projected = family
1900            .post_update_block_beta(
1901                &[],
1902                BinomialMeanWiggleFamily::BLOCK_WIGGLE,
1903                &spec,
1904                solver_slop,
1905            )
1906            .expect("active-set slop projects onto the exact cone");
1907        assert_eq!(projected[0], 0.0);
1908
1909        let material_violation = Array1::from_vec(vec![
1910            -2.0 * crate::wiggle::MONOTONE_WIGGLE_ACTIVE_SET_TOL,
1911            0.2,
1912            0.0,
1913        ]);
1914        assert!(
1915            family
1916                .post_update_block_beta(
1917                    &[],
1918                    BinomialMeanWiggleFamily::BLOCK_WIGGLE,
1919                    &spec,
1920                    material_violation,
1921                )
1922                .is_err(),
1923            "a material negative I-spline coefficient must be rejected"
1924        );
1925    }
1926
1927    /// WHY THE OUTER `[rho, psi]` SEARCH MUST RUN ON THE FROZEN ARM (gam#2748).
1928    ///
1929    /// The exact-joint spatial driver used to hand its outer evaluator this
1930    /// family with `frozen_warp_design = None`, while the baseline it starts from
1931    /// and the refit it mints are both the FROZEN arm. Two properties separate
1932    /// them, and both are read straight off `wiggle_geometry` and the row
1933    /// coefficient the joint Hessian is assembled from
1934    /// (`bmw_static_hessian_operator`: `coeff_eta = m2*a^2 + m1*b` with
1935    /// `a = dq/dq0`, `b = d2q/dq0^2`).
1936    ///
1937    /// **Convexity.** Frozen, `a = 1` and `b = 0` for every row, so `coeff_eta`
1938    /// collapses to `m2 = w*mu*(1-mu) >= 0` and the joint Hessian is
1939    /// `[X B_perp]' diag(m2) [X B_perp] + S`, positive semidefinite by
1940    /// construction. Dynamic, `b = B''(q0)*beta_w` is signed and `m1 = mu - y` is
1941    /// signed, so `coeff_eta` goes NEGATIVE on real rows — the arm is genuinely
1942    /// indefinite, which is what `resolvable_negative_curvature=true` on every
1943    /// seed of `papuan_oce4_matern_k6` was.
1944    ///
1945    /// **psi-independence.** The driver declares
1946    /// `CustomFamilyHyperLayout::new(vec![eta_derivs, Vec::new()], ..)`, i.e. that
1947    /// the wiggle block's design does not move with psi. Frozen, `block_geometry`
1948    /// returns the same matrix whatever the eta state is, so that is exactly
1949    /// true. Dynamic, it returns `B(eta)` and moves — so the declaration was
1950    /// false and the analytic outer gradient was missing
1951    /// `dB/dpsi = B'(eta)*(dX/dpsi)*beta`.
1952    ///
1953    /// Asserted here rather than argued in a comment, and asserted as a PAIR so
1954    /// neither half can pass by the fixture going quiet.
1955    #[test]
1956    fn the_frozen_arm_is_convex_and_psi_independent_and_the_dynamic_arm_is_neither_2748() {
1957        let n = 64;
1958        let knots = Array1::linspace(-3.0, 3.0, 10);
1959        let degree = 3;
1960        let dynamic = BinomialMeanWiggleFamily {
1961            y: Array1::from_shape_fn(n, |i| if i % 2 == 0 { 1.0 } else { 0.0 }),
1962            weights: Array1::ones(n),
1963            link_kind: InverseLink::Standard(StandardLink::Logit),
1964            wiggle_knots: knots.clone(),
1965            wiggle_degree: degree,
1966            policy: gam_runtime::resource::ResourcePolicy::default_library(),
1967            frozen_warp_design: None,
1968        };
1969        // An index that spans the knot range, and a SUPERCRITICAL warp: every
1970        // coefficient positive and large enough that `max_i B'(q0)_i . beta_w`
1971        // exceeds one, which is the regime `papuan_oce4_matern_k6` sits in
1972        // (`max_warp_slope` 4.5-9.0 on every pass of its baseline loop).
1973        let q0 = Array1::linspace(-2.5, 2.5, n);
1974        let basis_width = dynamic
1975            .wiggle_design(q0.view())
1976            .expect("value basis at the probe index")
1977            .ncols();
1978        let beta_w = Array1::from_elem(basis_width, 1.5);
1979
1980        let geom = dynamic
1981            .wiggle_geometry(q0.view(), beta_w.view())
1982            .expect("dynamic geometry");
1983        assert!(
1984            geom.dq_dq0.iter().cloned().fold(f64::MIN, f64::max) > 1.0,
1985            "NON-VACUITY: the fixture must be in the supercritical warp regime, else the \
1986             dynamic arm has nothing to be non-convex about (max dq/dq0 = {})",
1987            geom.dq_dq0.iter().cloned().fold(f64::MIN, f64::max),
1988        );
1989        let dynamic_min_coeff = (0..n)
1990            .map(|row| {
1991                let q = q0[row] + 0.0;
1992                let (m1, m2, _) = dynamic
1993                    .neglog_q_derivatives(dynamic.y[row], dynamic.weights[row], q)
1994                    .expect("row derivatives");
1995                let a = geom.dq_dq0[row];
1996                let b = geom.d2q_dq02[row];
1997                crate::gamlss::binomial_q_coeffs::hessian_coeff_fromobjective_q_terms(
1998                    m1, m2, a, a, b,
1999                )
2000            })
2001            .fold(f64::MAX, f64::min);
2002        assert!(
2003            dynamic_min_coeff < 0.0,
2004            "the dynamic arm's own eta row coefficient must go negative -- that IS the \
2005             indefiniteness the outer search could not solve through (min = {dynamic_min_coeff:.6e})"
2006        );
2007
2008        // The frozen arm, on the same data and the same warp.
2009        let frozen_basis = dynamic
2010            .wiggle_design(q0.view())
2011            .expect("frozen basis is the value basis at the frozen index");
2012        let frozen = BinomialMeanWiggleFamily {
2013            frozen_warp_design: Some(Arc::new(frozen_basis.clone())),
2014            ..dynamic.clone()
2015        };
2016        let frozen_geom = frozen
2017            .wiggle_geometry(q0.view(), beta_w.view())
2018            .expect("frozen geometry");
2019        assert!(
2020            frozen_geom.dq_dq0.iter().all(|value| *value == 1.0)
2021                && frozen_geom.d2q_dq02.iter().all(|value| *value == 0.0),
2022            "the freeze is exactly `dq/dq0 = 1`, `d2q/dq0^2 = 0`"
2023        );
2024        let frozen_min_coeff = (0..n)
2025            .map(|row| {
2026                let (m1, m2, _) = frozen
2027                    .neglog_q_derivatives(frozen.y[row], frozen.weights[row], q0[row])
2028                    .expect("row derivatives");
2029                crate::gamlss::binomial_q_coeffs::hessian_coeff_fromobjective_q_terms(
2030                    m1, m2, 1.0, 1.0, 0.0,
2031                )
2032            })
2033            .fold(f64::MAX, f64::min);
2034        assert!(
2035            frozen_min_coeff >= 0.0,
2036            "frozen, every row coefficient is `m2 = w*mu*(1-mu) >= 0`, so the joint Hessian \
2037             is a Gram plus penalties (min = {frozen_min_coeff:.6e})"
2038        );
2039
2040        // psi-independence: `block_geometry` is a constant function of the eta
2041        // state when frozen, and is not when dynamic.
2042        let spec = ParameterBlockSpec {
2043            name: "wiggle".to_string(),
2044            design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
2045                frozen_basis.clone(),
2046            )),
2047            offset: Array1::zeros(n),
2048            penalties: vec![],
2049            nullspace_dims: vec![],
2050            initial_log_lambdas: Array1::zeros(0),
2051            initial_beta: None,
2052            gauge_priority: 100,
2053            jacobian_callback: None,
2054            stacked_design: None,
2055            stacked_offset: None,
2056        };
2057        let states_at = |eta: Array1<f64>| {
2058            vec![
2059                ParameterBlockState {
2060                    beta: Array1::zeros(1),
2061                    eta,
2062                },
2063                ParameterBlockState {
2064                    beta: beta_w.clone(),
2065                    eta: Array1::zeros(n),
2066                },
2067            ]
2068        };
2069        let shifted = &q0 + 0.75;
2070        let geometry_of = |fam: &BinomialMeanWiggleFamily, eta: Array1<f64>| {
2071            fam.block_geometry(&states_at(eta), &spec)
2072                .expect("wiggle block geometry")
2073                .0
2074                .to_dense()
2075        };
2076        let frozen_here = geometry_of(&frozen, q0.clone());
2077        let frozen_there = geometry_of(&frozen, shifted.clone());
2078        assert_eq!(
2079            frozen_here, frozen_there,
2080            "frozen, the wiggle geometry does not move with the index, which is what the \
2081             driver's `vec![eta_derivs, Vec::new()]` psi layout asserts"
2082        );
2083        let dynamic_here = geometry_of(&dynamic, q0.clone());
2084        let dynamic_there = geometry_of(&dynamic, shifted);
2085        assert_ne!(
2086            dynamic_here, dynamic_there,
2087            "NON-VACUITY: dynamic, it MUST move -- otherwise the psi-layout claim would have \
2088             been true on that arm too and there would be nothing to repair"
2089        );
2090    }
2091
2092    /// The two blocks' COEFFICIENT COORDINATES, as this family answers for them
2093    /// (gam#2748), at the probe state `pre_fit_coefficient_coordinates` builds:
2094    /// warm start or zeros, at RAW block width.
2095    ///
2096    /// The warp coordinate is structural and the mean coordinate is not, and
2097    /// nothing declares that twice — `block_coefficient_coordinate`'s default
2098    /// derives it from `block_linear_constraints`, the very hook a
2099    /// reparameterisation would misread. So this test pins the DERIVATION
2100    /// against the constraint beside it: if the cone ever moves off the wiggle
2101    /// block, or onto the eta block, the declaration moves with it and this test
2102    /// keeps holding for the right reason rather than by coincidence.
2103    #[test]
2104    fn the_warp_coordinate_is_structural_and_the_mean_coordinate_is_not_2748() {
2105        let (family, wiggle_spec) = frozen_family_and_wiggle_spec();
2106        let n = family.y.len();
2107        let p_eta = 2;
2108        let mut eta_spec = wiggle_spec.clone();
2109        eta_spec.name = "eta".to_string();
2110        eta_spec.design = DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(
2111            Array2::<f64>::zeros((n, p_eta)),
2112        ));
2113        let specs = [eta_spec, wiggle_spec];
2114        let states: Vec<ParameterBlockState> = specs
2115            .iter()
2116            .map(|spec| ParameterBlockState {
2117                beta: Array1::zeros(spec.design.ncols()),
2118                eta: Array1::zeros(n),
2119            })
2120            .collect();
2121
2122        for (index, spec) in specs.iter().enumerate() {
2123            let cone = family
2124                .block_linear_constraints(&states, index, spec)
2125                .expect("constraint query at the probe state");
2126            let coordinate = family.block_coefficient_coordinate(&states, index, spec);
2127            assert_eq!(
2128                coordinate.is_structural(),
2129                cone.is_some(),
2130                "block '{}' declares coordinate {coordinate:?} while its cone is {}: the \
2131                 declaration and the constraint it is about must be one statement",
2132                spec.name,
2133                if cone.is_some() { "present" } else { "absent" },
2134            );
2135        }
2136
2137        // NON-VACUITY: the pair must actually differ, or the loop above would
2138        // pass on a family that answered the same way for both blocks.
2139        assert!(
2140            family
2141                .block_coefficient_coordinate(&states, BinomialMeanWiggleFamily::BLOCK_WIGGLE, &specs[1])
2142                .is_structural(),
2143            "the monotone warp coordinate carries the beta_w >= 0 cone",
2144        );
2145        assert!(
2146            family
2147                .block_coefficient_coordinate(&states, BinomialMeanWiggleFamily::BLOCK_ETA, &specs[0])
2148                .is_spanning(),
2149            "the mean block is an ordinary basis and must stay reparameterisable",
2150        );
2151    }
2152}