Skip to main content

gam_models/
multinomial_reml.rs

1//! `MultinomialFamily` — the `CustomFamily` adapter that lifts the inner
2//! penalized multinomial-logit driver in [`crate::multinomial`]
3//! into the joint exact-Newton outer REML/LAML surface.
4//!
5//! # Geometry
6//!
7//! For `K` classes with class `K − 1` as the reference, the parameter space
8//! is partitioned into `K − 1` blocks, one per active class:
9//!
10//! ```text
11//!     β = [ β_0 ; β_1 ; … ; β_{K-2} ],     β_a ∈ ℝ^P
12//! ```
13//!
14//! Each block shares the same design matrix `X ∈ ℝ^{N×P}` and the same
15//! list of per-smooth-term penalty components `S_t ∈ ℝ^{P×P}` (one `S_t` per
16//! smooth term `t`, each embedded at the term's `col_range` within the shared
17//! `P`-column coefficient space). Every active class block receives the FULL
18//! list, and the outer REML/LAML loop selects an **independent** smoothing
19//! parameter `λ_{a,t} = exp(ρ_{a,t})` per `(class a, term t)` — matching
20//! mgcv/VGAM per-term smoothing. The full per-class penalty is therefore
21//! `Σ_t λ_{a,t} S_t`, and the block-replicated penalty is
22//! `I_{K-1} ⊗ (Σ_t λ_{a,t} S_t)`. Pre-summing the terms into one fused `S`
23//! scaled by a single `λ_a` per class is exactly the multi-term fusion that
24//! over-smooths a rough term while under-smoothing a smooth one (#561), so the
25//! per-term list is carried through verbatim. The single-term case (`n_terms =
26//! 1`) degenerates to the classic `I_{K-1} ⊗ (λ_a S)` Kronecker form referenced
27//! by [`gam_solve::arrow_schur::KroneckerPenaltyOp`] when the outer solve
28//! later switches to matrix-free penalty application.
29//!
30//! # Likelihood
31//!
32//! The per-row log-likelihood, gradient, and dense Fisher / observed-information
33//! block all flow through [`MultinomialLogitLikelihood`], which is the canonical
34//! softmax-with-implicit-reference implementation. Because the logit is the
35//! canonical link of the multinomial family, observed = expected information
36//! row-wise, so the same `hess_block` payload that drives the inner Newton
37//! step also serves the outer Laplace / REML curvature.
38//!
39//! Stacked-coefficient ordering uses output-major layout
40//! `flat[a · P + i] = β[i, a]`, matching [`gam_solve::pirls::dense_block_xtwx`].
41//! The joint Hessian is then exactly
42//!
43//! ```text
44//!     H(β) = block( dense_block_xtwx(X, hess_block(η, y)) )
45//!          + diag_a( λ_a · S )
46//! ```
47//!
48//! and its β-dependence is genuine: row weights inside `hess_block` are
49//! `w_n · (δ_ab p_a − p_a p_b)`, so `D_β H` along a direction `d_β`
50//! contracts the softmax derivative `∂p_a/∂η_c = p_a (δ_ac − p_c)` against
51//! the row of `X d_β`. The directional-derivative kernels below implement
52//! this analytically.
53//!
54//! # Reference-class gauge
55//!
56//! Fixing `η_{K-1} ≡ 0` removes the softmax invariance under shifting all
57//! `η_a` by a common constant. No additional sum-to-zero projection is
58//! required at the η level. The cross-block gauge audit invoked by
59//! `fit_custom_family_with_rho_prior` still sees `K − 1` block designs that
60//! all share the same column span; the canonicaliser assigns ownership
61//! deterministically via the per-block `gauge_priority` listed below.
62
63use crate::block_layout::block_count::validate_block_count;
64use crate::custom_family::{
65    AdditiveBlockJacobian, BlockEffectiveJacobian, BlockWorkingSet, CustomFamily,
66    ExactNewtonJointGradientEvaluation, ExactNewtonJointHessianWorkspace, FamilyEvaluation,
67    FamilyLinearizationState, JointHessianSourcePreference, ParameterBlockSpec,
68    ParameterBlockState, PenaltyMatrix,
69};
70use crate::vector_response::{
71    MultinomialLogitLikelihood, VectorLikelihood, validate_multinomial_simplex,
72};
73use gam_linalg::faer_ndarray::{fast_ab, fast_atb};
74use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix, SymmetricMatrix};
75use gam_math::jet_scalar::{JetScalar, OneSeed, Order2, TwoSeed};
76use gam_math::nested_dual::JetField;
77use gam_problem::{HyperOperator, PseudoLogdetMode};
78use gam_solve::pirls::dense_block_xtwx;
79use ndarray::{Array1, Array2, Array3, ArrayView2};
80use rayon::prelude::*;
81use std::sync::{Arc, Mutex};
82
83#[inline]
84fn multinomial_stable_shift(eta: &[f64]) -> f64 {
85    eta.iter().copied().fold(0.0_f64, f64::max)
86}
87
88/// Canonical stable normalization for active logits plus an implicit zero
89/// reference logit. Every probability consumer, including prediction and the
90/// higher-order Fisher schedule, receives its base state from this function.
91/// The returned `(shift, log_centered_denominator)` keeps scalar likelihood
92/// lowerings in the same cancellation-free coordinates without repeating the
93/// exponential pass.
94#[inline(always)]
95pub(crate) fn multinomial_logit_probabilities_into(
96    eta: &[f64],
97    probabilities: &mut [f64],
98) -> (f64, f64) {
99    assert_eq!(probabilities.len(), eta.len() + 1);
100    let shift = multinomial_stable_shift(eta);
101    let active_classes = eta.len();
102    let reference_mass = (-shift).exp();
103    let mut denominator = reference_mass;
104    for (axis, &logit) in eta.iter().enumerate() {
105        let mass = (logit - shift).exp();
106        probabilities[axis] = mass;
107        denominator += mass;
108    }
109    let inverse_denominator = denominator.recip();
110    for probability in &mut probabilities[..active_classes] {
111        *probability *= inverse_denominator;
112    }
113    probabilities[active_classes] = reference_mass * inverse_denominator;
114    (shift, denominator.ln())
115}
116
117/// Production [`gam_math::jet_tower::RowProgram`] for one reference-coded
118/// multinomial-logit row.
119///
120/// Active-class logits are the `M` primaries and class `M` is the implicit
121/// reference with logit zero. The generic row NLL is the mechanical tower
122/// oracle for the retained normalized-softmax/Fisher lowerings in this module;
123/// production parity tests invoke this type directly rather than restating its
124/// expression under `cfg(test)`.
125#[derive(Clone, Copy, Debug)]
126pub struct MultinomialLogitRowProgram<'row> {
127    eta: &'row [f64],
128    response: &'row [f64],
129    weight: f64,
130}
131
132impl<'row> MultinomialLogitRowProgram<'row> {
133    /// Construct one validated row. `eta` contains the active-class logits and
134    /// `response` contains the complete simplex row, including the implicit
135    /// reference class in its last slot.
136    pub fn new(eta: &'row [f64], response: &'row [f64], weight: f64) -> Result<Self, String> {
137        let active_classes = eta.len();
138        if active_classes == 0 {
139            return Err("MultinomialLogitRowProgram requires at least one active class".into());
140        }
141        if response.len() != active_classes + 1 {
142            return Err(format!(
143                "MultinomialLogitRowProgram response length {} must equal active classes + reference = {}",
144                response.len(),
145                active_classes + 1,
146            ));
147        }
148        if !weight.is_finite() || weight < 0.0 {
149            return Err(format!(
150                "MultinomialLogitRowProgram weight must be finite and non-negative, got {weight}"
151            ));
152        }
153        if let Some((axis, value)) = eta
154            .iter()
155            .copied()
156            .enumerate()
157            .find(|(_, value)| !value.is_finite())
158        {
159            return Err(format!(
160                "MultinomialLogitRowProgram eta[{axis}] must be finite, got {value}"
161            ));
162        }
163        if let Some((class, value)) = response
164            .iter()
165            .copied()
166            .enumerate()
167            .find(|(_, value)| !value.is_finite() || *value < 0.0)
168        {
169            return Err(format!(
170                "MultinomialLogitRowProgram response[{class}] must be finite and non-negative, got {value}"
171            ));
172        }
173        let response_mass: f64 = response.iter().sum();
174        let simplex_tolerance = 1.0e-10 * (1.0 + response.len() as f64);
175        if (response_mass - 1.0).abs() > simplex_tolerance {
176            return Err(format!(
177                "MultinomialLogitRowProgram response must sum to one, got {response_mass}"
178            ));
179        }
180        Ok(Self {
181            eta,
182            response,
183            weight,
184        })
185    }
186
187    fn require_row(row: usize) -> Result<(), String> {
188        if row != 0 {
189            return Err(format!(
190                "MultinomialLogitRowProgram holds exactly one row; got row {row}"
191            ));
192        }
193        Ok(())
194    }
195
196    /// Stable shift shared by the semantic row expression and its compiled
197    /// probability/Fisher schedule. Including the reference logit zero keeps
198    /// every exponential argument non-positive.
199    #[inline]
200    fn stable_shift(&self) -> f64 {
201        multinomial_stable_shift(self.eta)
202    }
203
204    /// The one semantic row NLL over an arbitrary scalar field. Constants enter
205    /// through `constant`, allowing the same body to evaluate plain `f64` and
206    /// every fixed Taylor scalar selected by [`gam_math::jet_tower::RowProgram`].
207    ///
208    /// Centering the response term before adding the reference-class share avoids
209    /// the catastrophic `shift - observed_logit` cancellation that a conventional
210    /// `shift + log(sum(exp(eta-shift))) - y'eta` spelling suffers in saturated
211    /// tails. The identity uses `sum(response) = 1`:
212    ///
213    /// `NLL/w = log(D) - sum_active y_a(eta_a-shift) + y_ref*shift`.
214    fn eval_expression<S: JetField>(&self, primaries: &[S], constant: impl Fn(f64) -> S) -> S {
215        assert_eq!(primaries.len(), self.eta.len());
216        if self.weight == 0.0 {
217            return constant(0.0);
218        }
219        let shift = self.stable_shift();
220        let mut denominator = constant((-shift).exp());
221        let mut centered_response = constant(0.0);
222        for (axis, primary) in primaries.iter().enumerate() {
223            let centered = primary.add(&constant(-shift));
224            let exponential_value = centered.value().exp();
225            let exponential = centered.compose_unary([
226                exponential_value,
227                exponential_value,
228                exponential_value,
229                exponential_value,
230                exponential_value,
231            ]);
232            denominator = denominator.add(&exponential);
233            let response = self.response[axis];
234            if response != 0.0 {
235                centered_response = centered_response.add(&centered.scale(response));
236            }
237        }
238        let denominator_value = denominator.value();
239        let reciprocal = 1.0 / denominator_value;
240        let log_denominator = denominator.compose_unary([
241            denominator_value.ln(),
242            reciprocal,
243            -reciprocal * reciprocal,
244            2.0 * reciprocal * reciprocal * reciprocal,
245            -6.0 * reciprocal * reciprocal * reciprocal * reciprocal,
246        ]);
247        let reference_response = self.response[self.eta.len()];
248        let nll = log_denominator.sub(&centered_response);
249        let nll = if reference_response == 0.0 {
250            nll
251        } else {
252            nll.add(&constant(reference_response * shift))
253        };
254        nll.scale(self.weight)
255    }
256
257    /// Stable scalar NLL from the exact semantic expression.
258    #[inline]
259    pub(crate) fn negative_log_likelihood(&self) -> f64 {
260        self.eval_expression(self.eta, |value| value)
261    }
262
263    /// Compile the semantic normalized-softmax row into probabilities. The
264    /// returned shift and centered log-denominator use the same representation as
265    /// [`Self::eval_expression`]; no probability clamp or alternate tail policy
266    /// exists anywhere in the live likelihood.
267    #[inline(always)]
268    pub(crate) fn probabilities_into(&self, probabilities: &mut [f64]) -> (f64, f64) {
269        assert_eq!(probabilities.len(), self.response.len());
270        multinomial_logit_probabilities_into(self.eta, probabilities)
271    }
272
273    /// Scalar structure-compiled lowering of [`Self::eval_expression`] from a
274    /// normalization already produced for gradient/Hessian channels.
275    #[inline]
276    fn negative_log_likelihood_from_normalization(
277        &self,
278        shift: f64,
279        log_centered_denominator: f64,
280    ) -> f64 {
281        if self.weight == 0.0 {
282            return 0.0;
283        }
284        let mut centered_response = 0.0_f64;
285        for (axis, &response) in self.response[..self.eta.len()].iter().enumerate() {
286            if response != 0.0 {
287                centered_response += response * (self.eta[axis] - shift);
288            }
289        }
290        let reference_response = self.response[self.eta.len()];
291        let reference_term = if reference_response == 0.0 {
292            0.0
293        } else {
294            reference_response * shift
295        };
296        self.weight * (log_centered_denominator - centered_response + reference_term)
297    }
298
299    /// Structure-compiled value/gradient lowering of the semantic row. The
300    /// gradient is the NLL gradient; callers needing the log-likelihood negate
301    /// both channels. `inline(always)` so the const-hinted V/G/H shapes see
302    /// through to the normalization loops.
303    #[inline(always)]
304    pub(crate) fn value_gradient_into(
305        &self,
306        probabilities: &mut [f64],
307        gradient: &mut [f64],
308    ) -> f64 {
309        let active_classes = self.eta.len();
310        assert_eq!(gradient.len(), active_classes);
311        let (shift, log_centered_denominator) = self.probabilities_into(probabilities);
312        for axis in 0..active_classes {
313            gradient[axis] = self.weight * (probabilities[axis] - self.response[axis]);
314        }
315        self.negative_log_likelihood_from_normalization(shift, log_centered_denominator)
316    }
317
318    /// Diagonal-only structure-compiled Hessian lowering. This preserves the
319    /// O(M) preconditioner path without reintroducing a second softmax formula.
320    pub(crate) fn hessian_diagonal_into(&self, probabilities: &mut [f64], diagonal: &mut [f64]) {
321        let active_classes = self.eta.len();
322        assert_eq!(diagonal.len(), active_classes);
323        self.probabilities_into(probabilities);
324        for axis in 0..active_classes {
325            let probability = probabilities[axis];
326            diagonal[axis] = self.weight * probability * (1.0 - probability);
327        }
328    }
329
330    /// Structure-compiled value/gradient/Hessian lowering of the semantic row.
331    /// `gradient` is the NLL gradient and `hessian` is row-major. Both are
332    /// mechanically determined by the normalized masses produced above.
333    ///
334    /// Small class counts route through const-hinted instantiations of the
335    /// SAME body ([`Self::value_gradient_hessian_shaped`]): the release cell
336    /// showed the dynamic-length codegen losing ~15% to the fully unrolled
337    /// generic jet tower at `M ≤ 3` purely on loop/bounds overhead, so the
338    /// one structure-compiled formula is monomorphized at the shapes where
339    /// that overhead is a measurable fraction of the row cost. There is no
340    /// second formula and no alternate lowering — only a compile-time trip
341    /// count for the identical arithmetic.
342    pub(crate) fn value_gradient_hessian_into(
343        &self,
344        probabilities: &mut [f64],
345        gradient: &mut [f64],
346        hessian: &mut [f64],
347    ) -> f64 {
348        match self.eta.len() {
349            1 => self.value_gradient_hessian_shaped::<1>(probabilities, gradient, hessian),
350            2 => self.value_gradient_hessian_shaped::<2>(probabilities, gradient, hessian),
351            3 => self.value_gradient_hessian_shaped::<3>(probabilities, gradient, hessian),
352            4 => self.value_gradient_hessian_shaped::<4>(probabilities, gradient, hessian),
353            _ => self.value_gradient_hessian_shaped::<0>(probabilities, gradient, hessian),
354        }
355    }
356
357    /// The single V/G/H body behind [`Self::value_gradient_hessian_into`].
358    /// `M_HINT = 0` is the runtime-length instantiation; a nonzero hint pins
359    /// `active_classes` to a compile-time constant (checked, then used as the
360    /// trip count) so the loops unroll and the bounds checks vanish.
361    #[inline(always)]
362    fn value_gradient_hessian_shaped<const M_HINT: usize>(
363        &self,
364        probabilities: &mut [f64],
365        gradient: &mut [f64],
366        hessian: &mut [f64],
367    ) -> f64 {
368        let active_classes = if M_HINT == 0 {
369            self.eta.len()
370        } else {
371            assert_eq!(self.eta.len(), M_HINT);
372            M_HINT
373        };
374        assert_eq!(gradient.len(), active_classes);
375        assert_eq!(hessian.len(), active_classes * active_classes);
376        let value = self.value_gradient_into(probabilities, gradient);
377        for row in 0..active_classes {
378            let probability_row = probabilities[row];
379            for column in 0..active_classes {
380                let probability_column = probabilities[column];
381                hessian[row * active_classes + column] = self.weight
382                    * if row == column {
383                        probability_row * (1.0 - probability_column)
384                    } else {
385                        -probability_row * probability_column
386                    };
387            }
388        }
389        value
390    }
391}
392
393impl<const M: usize> gam_math::jet_tower::RowProgram<M> for MultinomialLogitRowProgram<'_> {
394    fn n_rows(&self) -> usize {
395        1
396    }
397
398    fn primaries(&self, row: usize) -> Result<[f64; M], String> {
399        Self::require_row(row)?;
400        self.eta.try_into().map_err(|_| {
401            format!(
402                "MultinomialLogitRowProgram has {} active logits but RowProgram dimension is {M}",
403                self.eta.len()
404            )
405        })
406    }
407
408    fn eval<S: JetScalar<M>>(&self, row: usize, p: &[S; M]) -> Result<S, String> {
409        Self::require_row(row)?;
410        if self.eta.len() != M {
411            return Err(format!(
412                "MultinomialLogitRowProgram has {} active logits but RowProgram dimension is {M}",
413                self.eta.len()
414            ));
415        }
416        Ok(self.eval_expression(p, S::constant))
417    }
418}
419
420/// Nilpotent coefficient selected from the canonical multinomial perturbation
421/// program below. `OneSeed<0>` selects the first directional derivative;
422/// `TwoSeed<0>` selects the mixed second directional derivative. There are no
423/// primary axes because this program differentiates only along supplied
424/// coefficient-space directions.
425///
426/// The pair of coefficient-space directions a Fisher perturbation is seeded
427/// along. First-directional seeds consume only `u`; the mixed second-directional
428/// seed consumes both. Bundling the pair keeps a single `seed` signature across
429/// both perturbation orders without forcing either impl to carry an unused
430/// positional argument.
431#[derive(Clone, Copy)]
432struct FisherDirection {
433    u: f64,
434    v: f64,
435}
436
437/// One perturbed active-class mass `p_a exp(delta_a)` together with the base
438/// point it was seeded from and the observation weight it is stored with.
439///
440/// The two perturbation orders reach the same normalized channels from
441/// different sides: the first-directional layout evaluates the scalar closed
442/// form in `probability`/`direction_u` (and folds `weight` straight into its
443/// single contiguous channel), while the mixed-second layout multiplies `mass`
444/// by the shared `inverse` denominator and leaves the weight for the assembled
445/// Fisher entry. Bundling the base point keeps one `channels` signature across
446/// both orders without forcing either impl to carry an unused positional
447/// argument — the same reason [`FisherDirection`] exists.
448#[derive(Clone, Copy)]
449struct PerturbedMass<S> {
450    probability: f64,
451    direction_u: f64,
452    weight: f64,
453    mass: S,
454}
455
456trait FisherPerturbation: JetScalar<0> {
457    type Channels: Copy;
458    const CONTIGUOUS_FULL: bool;
459    /// Where the single application of the observation weight lands. `true`
460    /// folds it into the stored channels (so the assembled Fisher entry is
461    /// built at unit weight); `false` leaves the channels unweighted and
462    /// applies the weight once to the assembled entry. Exactly one of the two
463    /// carries it, so the weight is never squared.
464    const WEIGHT_IN_CHANNELS: bool;
465
466    fn seed(direction: FisherDirection) -> Self;
467    fn coefficient(&self) -> f64;
468    fn from_channels(base: f64, channels: Self::Channels) -> Self;
469    /// Normalize one perturbed mass by the shared reciprocal denominator and
470    /// store the live nilpotent coefficients, applying the weight iff
471    /// [`Self::WEIGHT_IN_CHANNELS`].
472    fn channels(perturbed: &PerturbedMass<Self>, inverse: &Self) -> Self::Channels;
473    fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
474    where
475        F: Fn(usize) -> PerturbedMass<Self>;
476}
477
478impl FisherPerturbation for OneSeed<0> {
479    type Channels = f64;
480    const CONTIGUOUS_FULL: bool = true;
481    const WEIGHT_IN_CHANNELS: bool = true;
482
483    #[inline(always)]
484    fn seed(direction: FisherDirection) -> Self {
485        Self {
486            base: <Order2<0> as JetScalar<0>>::constant(0.0),
487            eps: <Order2<0> as JetScalar<0>>::constant(direction.u),
488        }
489    }
490
491    #[inline(always)]
492    fn coefficient(&self) -> f64 {
493        gam_math::nested_dual::JetField::value(&self.eps)
494    }
495
496    #[inline(always)]
497    fn from_channels(base: f64, channels: Self::Channels) -> Self {
498        Self {
499            base: <Order2<0> as JetScalar<0>>::constant(base),
500            eps: <Order2<0> as JetScalar<0>>::constant(channels),
501        }
502    }
503
504    #[inline(always)]
505    fn channels(perturbed: &PerturbedMass<Self>, inverse: &Self) -> Self::Channels {
506        // Only the single eps channel of `mass * inverse` survives at first
507        // order, and `mass = p_a (1 + eps u)` with `inverse.base = 1`, so the
508        // product collapses to this closed form without touching `mass`.
509        perturbed.probability
510            * (perturbed.direction_u + gam_math::nested_dual::JetField::value(&inverse.eps))
511            * perturbed.weight
512    }
513
514    #[inline(always)]
515    fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
516    where
517        F: Fn(usize) -> PerturbedMass<Self>,
518    {
519        let mut eps_coefficient = 0.0;
520        for a in 0..m {
521            eps_coefficient += gam_math::nested_dual::JetField::value(&perturbed_mass(a).mass.eps);
522        }
523        Self {
524            base: <Order2<0> as JetScalar<0>>::constant(1.0),
525            eps: <Order2<0> as JetScalar<0>>::constant(eps_coefficient),
526        }
527    }
528}
529
530impl FisherPerturbation for TwoSeed<0> {
531    type Channels = [f64; 3];
532    const CONTIGUOUS_FULL: bool = false;
533    const WEIGHT_IN_CHANNELS: bool = false;
534
535    #[inline(always)]
536    fn seed(direction: FisherDirection) -> Self {
537        Self {
538            base: <Order2<0> as JetScalar<0>>::constant(0.0),
539            eps: <Order2<0> as JetScalar<0>>::constant(direction.u),
540            del: <Order2<0> as JetScalar<0>>::constant(direction.v),
541            eps_del: <Order2<0> as JetScalar<0>>::constant(0.0),
542        }
543    }
544
545    #[inline(always)]
546    fn coefficient(&self) -> f64 {
547        gam_math::nested_dual::JetField::value(&self.eps_del)
548    }
549
550    #[inline(always)]
551    fn from_channels(base: f64, channels: Self::Channels) -> Self {
552        Self {
553            base: <Order2<0> as JetScalar<0>>::constant(base),
554            eps: <Order2<0> as JetScalar<0>>::constant(channels[0]),
555            del: <Order2<0> as JetScalar<0>>::constant(channels[1]),
556            eps_del: <Order2<0> as JetScalar<0>>::constant(channels[2]),
557        }
558    }
559
560    #[inline(always)]
561    fn channels(perturbed: &PerturbedMass<Self>, inverse: &Self) -> Self::Channels {
562        // Mixed second order keeps all three live channels of `mass * inverse`
563        // and leaves the weight on the assembled Fisher entry
564        // (`WEIGHT_IN_CHANNELS = false`), so it is not applied here.
565        let normalized = gam_math::nested_dual::JetField::mul(&perturbed.mass, inverse);
566        [
567            gam_math::nested_dual::JetField::value(&normalized.eps),
568            gam_math::nested_dual::JetField::value(&normalized.del),
569            gam_math::nested_dual::JetField::value(&normalized.eps_del),
570        ]
571    }
572
573    #[inline(always)]
574    fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
575    where
576        F: Fn(usize) -> PerturbedMass<Self>,
577    {
578        let mut denominator = Self::constant(1.0);
579        for a in 0..m {
580            let perturbed = perturbed_mass(a);
581            denominator = gam_math::nested_dual::JetField::add(
582                &denominator,
583                &gam_math::nested_dual::JetField::sub(
584                    &perturbed.mass,
585                    &Self::constant(perturbed.probability),
586                ),
587            );
588        }
589        denominator
590    }
591}
592
593#[inline(always)]
594fn fisher_entry<S: FisherPerturbation>(
595    probability_a: S,
596    probability_b: S,
597    diagonal: bool,
598    output_weight: f64,
599) -> f64 {
600    let negative_product = gam_math::nested_dual::JetField::neg(
601        &gam_math::nested_dual::JetField::mul(&probability_a, &probability_b),
602    );
603    let entry = if diagonal {
604        gam_math::nested_dual::JetField::add(&probability_a, &negative_product)
605    } else {
606        negative_product
607    };
608    gam_math::nested_dual::JetField::scale(&entry, output_weight).coefficient()
609}
610
611#[inline(always)]
612fn write_static_fisher<S: FisherPerturbation, F: Fn(usize) -> f64, const M: usize>(
613    probability: &F,
614    normalized: &[S::Channels],
615    fisher: &mut [f64],
616    output_weight: f64,
617) {
618    for a in 0..M {
619        let pa = S::from_channels(probability(a), normalized[a]);
620        fisher[a * M + a] = fisher_entry(pa, pa, true, output_weight);
621        for b in (a + 1)..M {
622            let pb = S::from_channels(probability(b), normalized[b]);
623            let coefficient = fisher_entry(pa, pb, false, output_weight);
624            fisher[a * M + b] = coefficient;
625            fisher[b * M + a] = coefficient;
626        }
627    }
628}
629
630#[derive(Clone, Copy, Eq, PartialEq)]
631enum FisherOutputSchedule {
632    SymmetricTriangle,
633    ContiguousFull,
634}
635
636const AVX2_WITHOUT_AVX512: bool = cfg!(all(target_arch = "x86_64", target_feature = "avx2"))
637    && !cfg!(all(target_arch = "x86_64", target_feature = "avx512f"));
638
639/// Select a storage schedule for the same elementwise [`fisher_entry`]
640/// expression. First-order M=32 favors contiguous rows on AVX2-only targets,
641/// while AVX-512 favors symmetric triangular writes; larger first-order blocks
642/// amortize the full-row arithmetic on every target. Mixed-second output stays
643/// triangular. The associated order and target-feature constants erase the
644/// inactive schedule during monomorphization.
645#[inline(always)]
646fn fisher_output_schedule<S: FisherPerturbation>(m: usize) -> FisherOutputSchedule {
647    if S::CONTIGUOUS_FULL && (m >= 64 || (m == 32 && AVX2_WITHOUT_AVX512)) {
648        FisherOutputSchedule::ContiguousFull
649    } else {
650        FisherOutputSchedule::SymmetricTriangle
651    }
652}
653
654/// Evaluate the one canonical active-class softmax/Fisher expression
655///
656/// `p_a(delta) = p_a exp(delta_a) / (1 + sum_c p_c (exp(delta_c) - 1))`
657///
658/// and `F_ab(delta) = weight * (indicator(a=b) p_a(delta) -
659/// p_a(delta) p_b(delta))`, then select the requested nilpotent coefficient.
660/// The implicit reference class is exactly the constant mass in the leading
661/// `1`; at the base point the denominator is bit-exactly one. The exponential
662/// and reciprocal derivative stacks are supplied at their fixed base points
663/// zero and one, so this performs no transcendental calls. Instantiating the
664/// same expression at `OneSeed<0>` or `TwoSeed<0>` yields every live first- and
665/// second-directional Fisher path without a dense class-axis derivative tower.
666/// Only live nilpotent coefficients survive between phases: one contiguous
667/// weighted first channel or three mixed-second channels. The generated output
668/// lowering specializes the common Fisher entry at `M=2,3,8,32`; at first
669/// order M=32 selects an ISA-shaped triangular or contiguous schedule, and
670/// M>=64 uses contiguous full rows. Mixed-second and arbitrary-width output
671/// retain the same triangular expression. These are storage/loop lowerings of
672/// this expression, not independent derivative formulas.
673#[inline(always)]
674fn softmax_fisher_perturbation<S: FisherPerturbation>(
675    m: usize,
676    weight: f64,
677    probability: impl Fn(usize) -> f64,
678    direction_u: impl Fn(usize) -> f64,
679    direction_v: impl Fn(usize) -> f64,
680    normalized: &mut [S::Channels],
681    fisher: &mut [f64],
682) {
683    assert_eq!(normalized.len(), m);
684    assert_eq!(fisher.len(), m * m);
685    // The observation weight is applied exactly once: either folded into the
686    // stored channels or applied to the assembled Fisher entry, never both.
687    let (channel_weight, output_weight) = if S::WEIGHT_IN_CHANNELS {
688        (weight, 1.0)
689    } else {
690        (1.0, weight)
691    };
692    let perturbed_mass = |a| {
693        let pa = probability(a);
694        let direction_u = direction_u(a);
695        let delta = S::seed(FisherDirection {
696            u: direction_u,
697            v: direction_v(a),
698        });
699        let mass = gam_math::nested_dual::JetField::scale(
700            &gam_math::nested_dual::JetField::compose_unary(&delta, [1.0; 5]),
701            pa,
702        );
703        PerturbedMass {
704            probability: pa,
705            direction_u,
706            weight: channel_weight,
707            mass,
708        }
709    };
710    let denominator = S::denominator(m, &perturbed_mass);
711    let inverse =
712        gam_math::nested_dual::JetField::compose_unary(&denominator, [1.0, -1.0, 2.0, -6.0, 24.0]);
713    for (a, channels) in normalized.iter_mut().enumerate() {
714        *channels = S::channels(&perturbed_mass(a), &inverse);
715    }
716    let lifted = |a| S::from_channels(probability(a), normalized[a]);
717    if m == 2 {
718        let p0 = lifted(0);
719        let p1 = lifted(1);
720        fisher[0] = fisher_entry(p0, p0, true, output_weight);
721        let off = fisher_entry(p0, p1, false, output_weight);
722        fisher[1] = off;
723        fisher[2] = off;
724        fisher[3] = fisher_entry(p1, p1, true, output_weight);
725        return;
726    }
727    if m == 3 {
728        let p0 = lifted(0);
729        let p1 = lifted(1);
730        let p2 = lifted(2);
731        fisher[0] = fisher_entry(p0, p0, true, output_weight);
732        let off01 = fisher_entry(p0, p1, false, output_weight);
733        fisher[1] = off01;
734        fisher[3] = off01;
735        let off02 = fisher_entry(p0, p2, false, output_weight);
736        fisher[2] = off02;
737        fisher[6] = off02;
738        fisher[4] = fisher_entry(p1, p1, true, output_weight);
739        let off12 = fisher_entry(p1, p2, false, output_weight);
740        fisher[5] = off12;
741        fisher[7] = off12;
742        fisher[8] = fisher_entry(p2, p2, true, output_weight);
743        return;
744    }
745    if m == 8 {
746        write_static_fisher::<S, _, 8>(&probability, normalized, fisher, output_weight);
747        return;
748    }
749    let output_schedule = fisher_output_schedule::<S>(m);
750    if m == 32 && output_schedule == FisherOutputSchedule::SymmetricTriangle {
751        write_static_fisher::<S, _, 32>(&probability, normalized, fisher, output_weight);
752        return;
753    }
754    if output_schedule == FisherOutputSchedule::ContiguousFull {
755        for a in 0..m {
756            let pa = lifted(a);
757            let row_start = a * m;
758            for b in 0..m {
759                fisher[row_start + b] = fisher_entry(pa, lifted(b), false, output_weight);
760            }
761            fisher[row_start + a] = fisher_entry(pa, pa, true, output_weight);
762        }
763        return;
764    }
765    for a in 0..m {
766        let pa = lifted(a);
767        fisher[a * m + a] = fisher_entry(pa, pa, true, output_weight);
768        for b in (a + 1)..m {
769            let coefficient = fisher_entry(pa, lifted(b), false, output_weight);
770            fisher[a * m + b] = coefficient;
771            fisher[b * m + a] = coefficient;
772        }
773    }
774}
775
776/// Numerical rank of a symmetric PSD penalty matrix, using the SAME relative
777/// zero classification as [`gam_problem::JointPenaltySpec::validate`]
778/// (`tol = 100·p·ε·max|eig|`), so the `nullspace_dim` a joint-spec builder
779/// declares from this rank always agrees with the spectrum the validator
780/// measures. A caller-declared structural nullity cannot be used for that
781/// purpose: identifiability-absorbed smooth penalties carry more
782/// numerical-zero directions than their structural claim (which is why the
783/// family no longer carries one).
784pub(crate) fn measured_penalty_rank(s: &Array2<f64>) -> Result<usize, String> {
785    Ok(s.nrows() - measured_penalty_nullspace(s)?.ncols())
786}
787
788/// An orthonormal basis of the numerical NULL SPACE of a PSD penalty operator —
789/// the directions no smoothing parameter reaches, because `S v = 0` implies
790/// `(H + S_λ)v = Hv` for every `λ` (#715's derivation, gam#2612).
791///
792/// This is the same measurement [`measured_penalty_rank`] reports as a count,
793/// and the two share one classification rule so a caller that needs the
794/// SUBSPACE and a caller that needs its DIMENSION can never disagree about
795/// which directions are penalized. The rule is the relative one a PSD Gram
796/// admits: an eigenvalue at or below `100·p·ε·λ_max` is zero at the precision
797/// its own assembly carries.
798///
799/// Returns a `p × k` matrix whose columns span `ker(S)`; `k = 0` means every
800/// direction is penalized, and `k = p` (the zero operator) means none is.
801pub(crate) fn measured_penalty_nullspace(s: &Array2<f64>) -> Result<Array2<f64>, String> {
802    // The Jeffreys geometry owns this classification, because the same subspace
803    // is what the term's own `Z_J` is built from; asking there keeps one rule
804    // for "which directions does this penalty reach" across both crates.
805    Ok(
806        gam_solve::estimate::reml::jeffreys_subspace::jeffreys_subspace_from_penalty(s.view())?
807            .columns,
808    )
809}
810
811/// The directions a symmetric PSD curvature `A` fails to bound: an orthonormal
812/// basis of the span of `A`'s eigenvectors whose eigenvalue is below ONE
813/// observation-equivalent of curvature (gam#2612).
814///
815/// The threshold is `CONDITIONING_GATE_ABSOLUTE`, not a fresh constant. That
816/// value is already the codebase's answer to "how much curvature does one
817/// observation contribute to a unit-scale direction" — a binomial Fisher weight
818/// `p(1−p) ≤ ¼`, a Gaussian unit weight `1` — and it is already what decides
819/// whether the Jeffreys term FIRES. Using it to decide the term's SUPPORT as
820/// well is the same statement asked once instead of twice: a direction holding
821/// less than a single observation's worth of curvature is not determined by the
822/// model, whether the missing curvature is the likelihood's or the penalty's.
823///
824/// `A` is expected in the reduced (identifiable) coordinates, so the returned
825/// columns are too; the caller lifts them through the same identifiable span it
826/// reduced with.
827pub(crate) fn under_identified_subspace(
828    a: &Array2<f64>,
829    metric: &Array2<f64>,
830) -> Result<Array2<f64>, String> {
831    gam_solve::estimate::reml::jeffreys_subspace::under_identified_subspace_in_metric(
832        a.view(),
833        metric.view(),
834    )
835}
836
837/// The reference-symmetric metric on the raw joint coefficient space,
838/// `M ⊗ I_P` in the class-major layout `θ[a·P + i] = β[i, a]` (gam#2612).
839///
840/// This is the CLR whitening factor of the softmax gauge — the same `M` the
841/// reference-symmetric penalty `M ⊗ S_t` is built from (gam#1587) — and it is
842/// what makes a curvature THRESHOLD a statement about the model rather than
843/// about which class happens to be the arbitrary baseline. Relabelling classes
844/// acts on `θ` by a non-orthogonal contrast change `R`, so `H + S_λ` transforms
845/// by congruence and its eigenvalues move; `M ⊗ I_P` transforms the same way, so
846/// generalized eigenvalues against it do not. See
847/// `under_identified_subspace_in_metric`.
848pub(crate) fn centered_class_coefficient_metric(m: usize, k: usize, p: usize) -> Array2<f64> {
849    // Scaled by `1/K`, and the scale is DERIVED rather than chosen.
850    //
851    // Any positive multiple of `M` is equally gauge-covariant, and the multiple
852    // moves every generalized eigenvalue — so it decides what the threshold
853    // MEANS, and picking it by what makes a fixture pass would be choosing an
854    // estimand on a curve. `CONDITIONING_GATE_ABSOLUTE`'s own derivation names
855    // the multiple: "one observation contributes at most `O(1)` curvature to a
856    // unit-scale direction (a binomial Fisher weight `p(1−p) ≤ ¼`, a Gaussian
857    // unit weight `1`)". The softmax's version of that quantity is exact. One
858    // observation's Fisher block in the ALR active frame is
859    // `W_ab = p_a(δ_ab − p_b)`, which at the most-informative point `p_c = 1/K`
860    // is
861    //
862    // ```text
863    //     W_ab = (1/K)(δ_ab − 1/K) = (1/K) · M_ab
864    // ```
865    //
866    // so `M/K` IS one maximally-informative observation's curvature per unit of
867    // design, and a generalized eigenvalue against it is literally "how many
868    // such observations does this direction hold". That is the constant's own
869    // sentence, transported into this family's geometry instead of borrowed from
870    // a binomial's.
871    let class_metric = centered_class_metric(m, k).mapv(|value| value / k as f64);
872    let dim = m * p;
873    let mut metric = Array2::<f64>::zeros((dim, dim));
874    for a in 0..m {
875        for b in 0..m {
876            let value = class_metric[[a, b]];
877            if value == 0.0 {
878                continue;
879            }
880            for i in 0..p {
881                metric[[a * p + i, b * p + i]] = value;
882            }
883        }
884    }
885    metric
886}
887
888/// The reference-symmetric class-space metric `M = I_m − J_m/K` (`m = K−1`
889/// active classes, `J` = all-ones), the closed-form CLR whitening factor of
890/// the softmax gauge (gam#1587). Symmetric positive-definite with eigenvalues
891/// `1` (multiplicity `m−1`) and `1/K` (once).
892pub(crate) fn centered_class_metric(m: usize, k: usize) -> Array2<f64> {
893    let inv_k = 1.0 / k as f64;
894    let mut metric = Array2::<f64>::from_elem((m, m), -inv_k);
895    for a in 0..m {
896        metric[[a, a]] += 1.0;
897    }
898    metric
899}
900
901/// The multinomial's per-class output-channel declaration, LOCKED to the raw
902/// coefficient width.
903///
904/// [`MultinomialFamily`] materialises its own shared design `X` at construction
905/// and every quantity it serves — the per-block working sets, the stacked joint
906/// gradient and Hessian, the Jeffreys/Firth information and all of their
907/// directional derivatives — is assembled from that captured `X` at the RAW
908/// width `P`, with the flat layout `(K−1)·P` ([`MultinomialFamily::beta_flat_dim`])
909/// as its single definition. The [`AdditiveBlockJacobian`] wrapped here exists
910/// only to tell the identifiability audit WHICH softmax output channel a block
911/// drives, so the audit does not mistake the `K−1` copies of the shared `X` for
912/// aliases (#363); it is not the source of the family's geometry.
913///
914/// That distinction is exactly what [`BlockEffectiveJacobian::locks_raw_width_reduction`]
915/// exists to express. Without it the canonicaliser took the `#933`
916/// gauge-composed reduction path, which is sound only for a family whose
917/// geometry is DERIVED from its callback: it column-reduced each class block to
918/// a full-rank subset (a rank-deficient shared design — `s(x) + s(z) +
919/// te(x, z)`, where the tensor term re-spans its own marginals — reduces to
920/// `15` of `19` columns per class) while the family kept assembling at `P = 19`.
921/// The two layouts then disagreed at the family's own guard,
922///
923/// ```text
924/// MultinomialFamily joint gradient: 2 block specs carry 30 coefficients but the
925/// family's flat layout is 2 classes x 19 columns = 38
926/// ```
927///
928/// and the fit refused every trial point (#2744). Locking the width makes the
929/// family's design the ONE layout definition again: the specs, the assemblies
930/// and the guard all read `P`. The weak directions the audit finds are handled
931/// where every other raw-width family handles them — by the penalty nullspace
932/// and the Levenberg-damped / Firth inner solve — not by design surgery the
933/// family cannot see.
934struct MultinomialClassChannelJacobian {
935    inner: AdditiveBlockJacobian,
936}
937
938impl MultinomialClassChannelJacobian {
939    fn new(inner: AdditiveBlockJacobian) -> Self {
940        Self { inner }
941    }
942}
943
944impl BlockEffectiveJacobian for MultinomialClassChannelJacobian {
945    fn effective_jacobian_rows(
946        &self,
947        state: &FamilyLinearizationState<'_>,
948        rows: std::ops::Range<usize>,
949    ) -> Result<Array2<f64>, String> {
950        self.inner.effective_jacobian_rows(state, rows)
951    }
952
953    fn n_outputs(&self) -> usize {
954        self.inner.n_outputs()
955    }
956
957    fn locks_raw_width_reduction(&self) -> bool {
958        true
959    }
960}
961
962/// Joint-coupled multinomial-logit family with shared design and shared
963/// smoothing penalty across active classes.
964///
965/// # Block layout
966///
967/// `K − 1` parameter blocks, indexed `a = 0..K-1`, each carrying coefficient
968/// vector `β_a ∈ ℝ^P`. Class `K − 1` is the reference (`β_{K-1} ≡ 0`) and
969/// does not appear in the block list.
970///
971/// # Invariants
972///
973/// * `y_one_hot.dim() == (N, K)`, with `K = total_classes ≥ 2`.
974/// * `weights.len() == N`, finite and non-negative.
975/// * `design.nrows() == N`, `design.ncols() == P`.
976/// * every penalty in `penalties` has shape `(P, P)` (symmetric, PSD).
977///
978/// All are validated by [`MultinomialFamily::new`].
979#[derive(Clone, Debug)]
980pub struct MultinomialFamily {
981    /// Categorical response matrix `Y ∈ ℝ^{N × K}`. Each row must be a point on
982    /// the probability simplex (`y_c ≥ 0`, `Σ_c y_c = 1`): a one-hot indicator
983    /// or a label-smoothed probability vector. Rows whose mass departs from 1
984    /// are rejected by [`MultinomialFamily::new`] — the softmax residual and
985    /// Fisher block are the derivatives of `Σ_c y_c log p_c` only under the
986    /// simplex constraint. Column `K − 1` is the reference class.
987    pub y_one_hot: Array2<f64>,
988    /// Per-row weights `w ∈ ℝ^N`, finite and non-negative.
989    pub weights: Array1<f64>,
990    /// Total class count `K ≥ 2`. Active classes are `0..K-1`; class
991    /// `K − 1` is the reference.
992    pub total_classes: usize,
993    /// Shared design matrix `X ∈ ℝ^{N × P}`, identical across all active
994    /// classes. Carried as `Arc<Array2<f64>>` so the per-block specs and the
995    /// family share storage with zero copies.
996    pub design: Arc<Array2<f64>>,
997    /// Per-smooth-term penalty components, each a `P × P` operator expressed in
998    /// block-local form (`PenaltyMatrix::Blockwise` embedding the term's local
999    /// `S_t` at its `col_range` within the shared `P`-column coefficient
1000    /// space). **Every active class block receives this entire list**, so the
1001    /// outer REML/LAML loop selects an *independent* smoothing parameter per
1002    /// `(class, term)` — matching mgcv/VGAM per-term smoothing. The full
1003    /// block-replicated penalty is `I_{K-1} ⊗ (Σ_t λ_{a,t} S_t)`; pre-summing
1004    /// the terms (one fused λ per class) is exactly the multi-term fusion that
1005    /// over-smooths one term while under-smoothing another (#561). Carried as
1006    /// `Arc<Vec<…>>` so per-block specs share storage with zero copies.
1007    pub penalties: Arc<Vec<PenaltyMatrix>>,
1008    /// Cached likelihood evaluator. Constructed once with the same row
1009    /// weights as `weights` and reused across every `evaluate` call.
1010    likelihood: MultinomialLogitLikelihood,
1011    /// Memo for the FULL set of canonical-axis joint-Hessian directional
1012    /// derivatives `{ Hdot[e_k] }_{k=0..(K-1)·P}` at one frozen `β`.
1013    ///
1014    /// The Tier-B Jeffreys/Firth term (`joint_jeffreys_term`) drives the inner
1015    /// loop `for k in 0..p { hessian_dir(e_k) }`, calling
1016    /// [`Self::exact_newton_joint_hessian_directional_derivative`] once PER
1017    /// canonical axis at the SAME `block_states`. Each call independently
1018    /// recomputed the full `(N,K)` softmax and re-formed a generic
1019    /// `dense_block_xtwx` Gram — `O(p)` redundant softmax passes per term, and
1020    /// the term itself is rebuilt at every accepted inner-Newton β and every
1021    /// outer LAML eval (#715/#722/#753: the multinomial Firth grind). This memo
1022    /// assembles the WHOLE axis set in one softmax pass the first time an axis
1023    /// is requested at a given β, then serves every subsequent axis (the rest of
1024    /// that Jeffreys loop) from the cache. Keyed on an η fingerprint so a moved
1025    /// β recomputes; a single-slot cache suffices because the Jeffreys loop
1026    /// requests all `p` axes consecutively before β changes.
1027    ///
1028    /// `Arc<Mutex<…>>` (interior mutability) because the family is shared
1029    /// `&self` and `Clone`; the per-axis derivative is a pure function of the
1030    /// frozen `β`, so a stale clone simply recomputes — never returns a wrong
1031    /// value. Cheap clones share the slot.
1032    axis_derivative_cache: Arc<Mutex<Option<AxisDerivativeCache>>>,
1033    /// Whether this family instance contributes the full-span Jeffreys/Firth
1034    /// correction to the coupled custom-family solve.
1035    ///
1036    /// The formula REML entry (`fit_penalized_multinomial_formula`) arms this
1037    /// CONDITIONALLY (#715/#753): attempt 1 fits with it disarmed (the unbiased
1038    /// criterion — no Firth shrinkage toward the uniform simplex on interior
1039    /// data); on separation evidence (failed solve, non-finite or saturated
1040    /// logits) the fit is re-run once with it armed, because a penalty-null
1041    /// direction `v` (`Sv = 0`) under softmax saturation has `(H + S_λ)v → 0`
1042    /// for EVERY ρ — only a proper prior on that quotient-null subspace can
1043    /// bound it, never a smoothing parameter.
1044    joint_jeffreys_term_strength: f64,
1045    /// Warm-start seed `log λ` for the reference-symmetric joint smoothing
1046    /// penalties (gam#1587). The formula REML driver overrides this from its
1047    /// `init_lambda` so the joint-penalty outer ρ starts at the same seed the
1048    /// per-block path used historically; the outer loop then selects the true
1049    /// optimum. Defaults to `0.0` (`λ = 1`).
1050    initial_log_lambda: f64,
1051    /// Optional PER-SPEC warm-start seeds for the joint smoothing penalties,
1052    /// overriding the shared `initial_log_lambda` (one entry per joint spec, in
1053    /// the builders' term-major spec order). This is how a caller follows the
1054    /// outer refusal's "resume by seeding the outer search at rho_checkpoint"
1055    /// hint for a joint-penalty family — the checkpoint is a PER-SPEC ρ vector
1056    /// a single shared seed cannot express — and how fixed-ρ diagnostics pin
1057    /// the joint λs when probing the criterion surface (#2349).
1058    joint_initial_log_lambdas: Option<Vec<f64>>,
1059    /// The MEASURED directions the model fails to bound at the smoothing it
1060    /// selected, as an orthonormal `(K−1)P × m` basis in the raw joint
1061    /// coefficient order — the span the Jeffreys/Firth term acts on (gam#2612).
1062    ///
1063    /// `None` (the default) falls back to `jeffreys_span_aggregate_penalty`,
1064    /// i.e. `ker(S_λ)`. The formula REML driver sets this from the separation
1065    /// certificate, which measures it ONCE at the unbiased probe's certified
1066    /// mode and at the λ that probe selected, so it is constant for the lifetime
1067    /// of the armed refit — the constancy every `Φ` derivative formula needs.
1068    /// See `CustomFamily::jeffreys_span_basis` for why a penalty kernel is the
1069    /// wrong object to derive this from.
1070    ///
1071    /// `Arc` because the family is cloned per homotopy waypoint and per outer
1072    /// evaluation, and this basis never changes within a fit.
1073    joint_jeffreys_span: Option<Arc<Array2<f64>>>,
1074}
1075
1076/// One frozen-`β` snapshot of every canonical-axis joint-Hessian directional
1077/// derivative, shared across the `p` sequential per-axis requests the Tier-B
1078/// Jeffreys loop makes at that `β` (see [`MultinomialFamily::axis_derivative_cache`]).
1079#[derive(Clone, Debug)]
1080struct AxisDerivativeCache {
1081    /// Fingerprint of the stacked per-class `η` the derivatives were built at.
1082    eta_key: EtaFingerprint,
1083    /// `Hdot[e_k]` for every canonical axis `k = a·P + i`, laid out in the same
1084    /// output-major flat order as the joint Hessian.
1085    derivatives: Vec<Array2<f64>>,
1086}
1087
1088/// Cheap, exact fingerprint of a stacked `(N, M)` η matrix: its raw `f64` bit
1089/// patterns hashed. Two identical `β` snapshots produce identical η bit-for-bit
1090/// (the Jeffreys loop never perturbs β between axis requests), so this keys the
1091/// single-slot axis-derivative memo without storing the whole η.
1092#[derive(Clone, Debug, PartialEq, Eq)]
1093struct EtaFingerprint {
1094    rows: usize,
1095    cols: usize,
1096    hash: u64,
1097}
1098
1099impl EtaFingerprint {
1100    fn of(eta: ArrayView2<'_, f64>) -> Self {
1101        use std::hash::{Hash, Hasher};
1102        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1103        let (rows, cols) = eta.dim();
1104        rows.hash(&mut hasher);
1105        cols.hash(&mut hasher);
1106        for &v in eta.iter() {
1107            v.to_bits().hash(&mut hasher);
1108        }
1109        EtaFingerprint {
1110            rows,
1111            cols,
1112            hash: hasher.finish(),
1113        }
1114    }
1115}
1116
1117impl MultinomialFamily {
1118    /// Total number of active blocks, `M = K − 1`.
1119    pub const fn active_classes(&self) -> usize {
1120        self.total_classes - 1
1121    }
1122
1123    /// Validate inputs and construct the family.
1124    ///
1125    /// All shape and finiteness invariants are checked here so the
1126    /// `CustomFamily` methods can rely on pre-validated geometry.
1127    pub fn new(
1128        y_one_hot: Array2<f64>,
1129        weights: Array1<f64>,
1130        total_classes: usize,
1131        design: Arc<Array2<f64>>,
1132        penalties: Arc<Vec<PenaltyMatrix>>,
1133    ) -> Result<Self, String> {
1134        if total_classes < 2 {
1135            return Err(format!(
1136                "MultinomialFamily requires K ≥ 2 classes (got {total_classes})"
1137            ));
1138        }
1139        let (n, k) = y_one_hot.dim();
1140        if k != total_classes {
1141            return Err(format!(
1142                "MultinomialFamily: y_one_hot has {k} columns but total_classes = {total_classes}"
1143            ));
1144        }
1145        if weights.len() != n {
1146            return Err(format!(
1147                "MultinomialFamily: weights length {} != N = {n}",
1148                weights.len()
1149            ));
1150        }
1151        for (i, &v) in weights.iter().enumerate() {
1152            if !(v.is_finite() && v >= 0.0) {
1153                return Err(format!(
1154                    "MultinomialFamily: weights[{i}] must be finite and non-negative (got {v})"
1155                ));
1156            }
1157        }
1158        if design.nrows() != n {
1159            return Err(format!(
1160                "MultinomialFamily: design has {} rows, expected {n}",
1161                design.nrows()
1162            ));
1163        }
1164        let p = design.ncols();
1165        for (t, penalty) in penalties.iter().enumerate() {
1166            if penalty.shape() != (p, p) {
1167                return Err(format!(
1168                    "MultinomialFamily: penalties[{t}] shape {:?} != (P, P) = ({p}, {p})",
1169                    penalty.shape()
1170                ));
1171            }
1172            for ((i, j), &v) in penalty.to_dense().indexed_iter() {
1173                if !v.is_finite() {
1174                    return Err(format!(
1175                        "MultinomialFamily: penalties[{t}][{i},{j}] must be finite (got {v})"
1176                    ));
1177                }
1178            }
1179        }
1180        validate_multinomial_simplex(y_one_hot.view(), "MultinomialFamily")
1181            .map_err(|e| e.to_string())?;
1182        for ((i, j), &v) in design.indexed_iter() {
1183            if !v.is_finite() {
1184                return Err(format!(
1185                    "MultinomialFamily: design[{i},{j}] must be finite (got {v})"
1186                ));
1187            }
1188        }
1189
1190        // Likelihood owns its own copy of the row weights so the family is
1191        // self-contained — `evaluate` does not need to refresh it.
1192        let likelihood = MultinomialLogitLikelihood::with_classes(total_classes)
1193            .map_err(|e| format!("MultinomialFamily: {e}"))?
1194            .with_row_weights(weights.clone())
1195            .map_err(|e| format!("MultinomialFamily: {e}"))?;
1196
1197        Ok(Self {
1198            y_one_hot,
1199            weights,
1200            total_classes,
1201            design,
1202            penalties,
1203            likelihood,
1204            axis_derivative_cache: Arc::new(Mutex::new(None)),
1205            joint_jeffreys_term_strength: 1.0,
1206            initial_log_lambda: 0.0,
1207            joint_initial_log_lambdas: None,
1208            joint_jeffreys_span: None,
1209        })
1210    }
1211
1212    /// Install the MEASURED Jeffreys/Firth span (gam#2612).
1213    ///
1214    /// The caller is promising the basis is orthonormal, is expressed in the raw
1215    /// joint coefficient order, and does not change for the lifetime of the fit.
1216    /// See `Self::joint_jeffreys_span` and `CustomFamily::jeffreys_span_basis`.
1217    pub fn with_joint_jeffreys_span(mut self, span: Option<Arc<Array2<f64>>>) -> Self {
1218        self.joint_jeffreys_span = span;
1219        self
1220    }
1221
1222    /// Select whether this multinomial adapter instance contributes the
1223    /// full-span Jeffreys/Firth correction.
1224    pub fn with_joint_jeffreys_term(mut self, enabled: bool) -> Self {
1225        self.joint_jeffreys_term_strength = f64::from(enabled);
1226        self
1227    }
1228
1229    /// Seed the warm-start `log λ` carried into the reference-symmetric joint
1230    /// smoothing penalties (gam#1587). The formula REML driver sets this from its
1231    /// `init_lambda` so the joint-penalty outer ρ starts at the same seed the
1232    /// per-block path used historically; the outer loop then selects the optimum.
1233    pub fn with_initial_log_lambda(mut self, log_lambda: f64) -> Self {
1234        self.initial_log_lambda = log_lambda;
1235        self
1236    }
1237
1238    /// Seed PER-SPEC warm-start `log λ` values for the joint smoothing
1239    /// penalties, in the builders' term-major spec order (equivariant carrier:
1240    /// `s = t·K + c`; shared centered carrier: `s = t`). Overrides the shared
1241    /// [`Self::with_initial_log_lambda`] seed entry-by-entry; the spec builders
1242    /// reject a wrong length. This is the resume path for a joint-penalty
1243    /// `rho_checkpoint` and the fixed-ρ pin for criterion diagnostics (#2349).
1244    pub fn with_joint_initial_log_lambdas(mut self, seeds: Vec<f64>) -> Self {
1245        self.joint_initial_log_lambdas = Some(seeds);
1246        self
1247    }
1248
1249    /// Per-spec joint warm-start seed: the override entry when present, else
1250    /// the shared `initial_log_lambda`.
1251    fn joint_seed(&self, spec_index: usize) -> f64 {
1252        self.joint_initial_log_lambdas
1253            .as_ref()
1254            .and_then(|seeds| seeds.get(spec_index))
1255            .copied()
1256            .unwrap_or(self.initial_log_lambda)
1257    }
1258
1259    /// Validate an override seed vector against the joint-spec count the
1260    /// builder is about to produce.
1261    fn validate_joint_seed_len(&self, expected: usize, carrier: &str) -> Result<(), String> {
1262        match self.joint_initial_log_lambdas.as_ref() {
1263            Some(seeds) if seeds.len() != expected => Err(format!(
1264                "multinomial {carrier} carrier: joint_initial_log_lambdas has {} entries, \
1265                 expected {expected} (one per joint spec, term-major)",
1266                seeds.len()
1267            )),
1268            _ => Ok(()),
1269        }
1270    }
1271
1272    /// Build the canonical block specs for this family.
1273    ///
1274    /// One [`ParameterBlockSpec`] per active class, all sharing the same
1275    /// design (zero-copy through `Arc<Array2<f64>>`) and an independent
1276    /// `PenaltyMatrix::Dense` copy of `S`. The `gauge_priority` is set so
1277    /// that the active class **closest to the reference** owns shared
1278    /// affine / null-space directions: class `a` gets priority
1279    /// `100 + (M − a)`. Class `0` (farthest from the reference) is the most
1280    /// likely to retain a shared direction in canonicalisation; class
1281    /// `M − 1` is the least likely. This matches the task's
1282    /// "descending priorities" gauge convention.
1283    ///
1284    /// `initial_log_lambdas` is initialised to zeros (one entry per penalty
1285    /// term per block: each block carries one `λ_{a,t}` per smooth term `t`).
1286    /// Callers that want a custom warm start override per-block before passing
1287    /// to `fit_custom_family_with_rho_prior`.
1288    pub fn build_block_specs(&self) -> Vec<ParameterBlockSpec> {
1289        let m = self.active_classes();
1290        (0..m)
1291            .map(|a| {
1292                let priority = 100u8.saturating_add(u8::try_from(m - a).unwrap_or(u8::MAX));
1293                // Each active class drives a *separate* softmax channel
1294                // `η_a = X β_a`. The K−1 blocks share the identical design `X`,
1295                // but they are **not** gauge-redundant aliases: the true joint
1296                // Jacobian is block-diagonal `blkdiag(X, …, X)` with full rank
1297                // `(K−1)·P`. Supplying an `AdditiveBlockJacobian` that places
1298                // block `a`'s design in its own output channel routes
1299                // canonicalisation through the channel-aware identifiability
1300                // audit (one output per class). Without it the flat audit
1301                // assembles `[X | X | … | X]` over the same N rows, mistakes the
1302                // repeated columns for aliases, and strips every block past
1303                // `class_0` to width 0 — the failure in #363. The declaration
1304                // is wrapped in `MultinomialClassChannelJacobian` so it ALSO
1305                // says the block owns its geometry at raw width: this family
1306                // assembles from the `X` it captured, never from the callback,
1307                // so a column-reduced block would denominate β in a width the
1308                // family's flat layout does not know about (#2744).
1309                //
1310                // The per-class blocks attach NO smooth penalty: the sole
1311                // smoothing carrier is the permutation-equivariant per-class
1312                // centered joint family `λ_{t,c}·(C_cᵀC_c ⊗ S_t)` (see
1313                // `equivariant_class_penalty_specs`). Penalizing the ALR
1314                // contrasts β_a here would re-anchor smoothness to the
1315                // arbitrary reference class (#1587) — and attaching both
1316                // carriers would double-count. Heterogeneous per-class
1317                // smoothness (#1855) survives as the per-class λ_{t,c} on the
1318                // gauge-free centered functions.
1319                let mut spec = ParameterBlockSpec {
1320                    name: format!("class_{a}"),
1321                    design: DesignMatrix::Dense(DenseDesignMatrix::from(self.design.clone())),
1322                    offset: Array1::<f64>::zeros(self.design.nrows()),
1323                    penalties: Vec::new(),
1324                    nullspace_dims: Vec::new(),
1325                    initial_log_lambdas: Array1::<f64>::zeros(0),
1326                    initial_beta: None,
1327                    gauge_priority: priority,
1328                    jacobian_callback: None,
1329                    stacked_design: None,
1330                    stacked_offset: None,
1331                };
1332                spec.jacobian_callback = Some(Arc::new(MultinomialClassChannelJacobian::new(
1333                    AdditiveBlockJacobian {
1334                        design: (*self.design).clone(),
1335                        own_output: a,
1336                        n_family_outputs: m,
1337                    },
1338                )));
1339                spec
1340            })
1341            .collect()
1342    }
1343
1344    /// Total stacked-coefficient dimension `(K − 1) · P`.
1345    pub fn beta_flat_dim(&self) -> usize {
1346        self.active_classes() * self.design.ncols()
1347    }
1348
1349    /// Cross-check the caller's per-block specs against this family's flat
1350    /// coefficient layout.
1351    ///
1352    /// Every exact-Newton joint quantity we hand back (gradient, Hessian
1353    /// operator) is laid out as `m` contiguous `p`-wide blocks in spec order,
1354    /// so a spec list whose combined coefficient width disagrees with
1355    /// [`Self::beta_flat_dim`] would silently misalign the caller's flattened
1356    /// `β`. Callers are allowed to omit the specs entirely (the trait passes an
1357    /// empty slice when it has none to offer); an empty list carries no layout
1358    /// claim and is accepted.
1359    fn check_spec_coefficient_width(
1360        &self,
1361        specs: &[ParameterBlockSpec],
1362        what: &str,
1363    ) -> Result<(), String> {
1364        if specs.is_empty() {
1365            return Ok(());
1366        }
1367        let spec_width: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
1368        let flat_dim = self.beta_flat_dim();
1369        if spec_width != flat_dim {
1370            return Err(format!(
1371                "MultinomialFamily {what}: {} block specs carry {spec_width} coefficients but the \
1372                 family's flat layout is {} classes x {} columns = {flat_dim}",
1373                specs.len(),
1374                self.active_classes(),
1375                self.design.ncols()
1376            ));
1377        }
1378        Ok(())
1379    }
1380
1381    /// Build the reference-symmetric ("centered") full-width smoothing
1382    /// penalties `λ_t · (M ⊗ S_t)`, one per smooth term `t`, in raw stacked
1383    /// (class-major) coordinates `[β_0; …; β_{K-2}]` (gam#1587).
1384    ///
1385    /// `M = I_{K-1} − J_{K-1}/K` is the closed-form CLR whitening metric of the
1386    /// softmax class gauge (the multinomial analogue of the resolved ALR
1387    /// sibling #1549). The quadratic form `βᵀ (M ⊗ S_t) β` equals the symmetric
1388    /// CLR penalty `Σ_{k=0}^{K-1} β̃_{k}ᵀ S_t β̃_{k}` over centered coefficients
1389    /// `β̃_k = β_k − (1/K)Σ_b β_b` (`β_{K-1} ≡ 0`), a symmetric function of all
1390    /// `K` classes — so the penalized fit no longer depends on which class is
1391    /// the arbitrary softmax reference. Block `(a, b)` of the returned
1392    /// `(M·P)×(M·P)` matrix is `M[a,b]·S_t`; `M` is SPD (eigenvalues `1` with
1393    /// multiplicity `K−2` and `1/K` once), so each `M ⊗ S_t` is PSD with
1394    /// `nullspace_dim = (K−1)·nullspace_dim(S_t)`.
1395    ///
1396    /// Every spec carries the per-term precision label `multinomial_term_{t}`
1397    /// so the outer loop ties one shared `λ_t` across all classes (the gauge
1398    /// the centered metric requires; an untied per-(class,term) `λ` is itself a
1399    /// second source of reference dependence).
1400    pub fn centered_joint_penalty_specs(
1401        &self,
1402    ) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
1403        let m = self.active_classes();
1404        let k = self.total_classes;
1405        let p = self.design.ncols();
1406        let metric = centered_class_metric(m, k);
1407        let raw_total = m * p;
1408        self.validate_joint_seed_len(self.penalties.len(), "shared centered")?;
1409        self.penalties
1410            .iter()
1411            .enumerate()
1412            .map(|(t, pen)| {
1413                let s_t = pen.to_dense();
1414                let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
1415                for a in 0..m {
1416                    for b in 0..m {
1417                        let scale = metric[[a, b]];
1418                        for i in 0..p {
1419                            for j in 0..p {
1420                                matrix[[a * p + i, b * p + j]] = scale * s_t[[i, j]];
1421                            }
1422                        }
1423                    }
1424                }
1425                // rank(M ⊗ S_t) = m · rank(S_t); measure rank(S_t) with the
1426                // validator's own zero classification (a structural nullity
1427                // claim understates the numerical nullity for
1428                // identifiability-absorbed smooths).
1429                let rank_s = measured_penalty_rank(&s_t)
1430                    .map_err(|e| format!("multinomial centered penalty term {t}: {e}"))?;
1431                Ok(gam_problem::JointPenaltySpec {
1432                    label: Some(format!("multinomial_term_{t}")),
1433                    matrix,
1434                    initial_log_lambda: self.joint_seed(t),
1435                    nullspace_dim: raw_total - m * rank_s,
1436                    // One spec per term here, but declare it anyway so the
1437                    // shared-centered and equivariant carriers group alike.
1438                    group: Some(t),
1439                })
1440            })
1441            .collect()
1442    }
1443
1444    /// Build the permutation-EQUIVARIANT heterogeneous smoothing penalties:
1445    /// for each smooth term `t`, `K` per-class penalties
1446    /// `λ_{t,c} · γ_cᵀ S_t γ_c` on the CENTERED class functions
1447    /// `γ_c = β_c − (1/K)Σ_b β_b` (with `β_ref ≡ 0`), one λ per class —
1448    /// including the softmax reference class.
1449    ///
1450    /// This is the resolution of the #1587 (reference invariance) vs #1855
1451    /// (heterogeneous per-class smoothness) tension. The reverted per-block
1452    /// carrier penalized the ALR contrasts `β_a = γ_a − γ_ref`, whose
1453    /// "per-class" smoothness is an artifact of which class is the baseline
1454    /// (the family of diagonal ALR precisions is not closed under reference
1455    /// changes). Penalizing the centered functions is reference-free by
1456    /// construction: relabeling classes permutes the (γ_c, λ_{t,c}) pairs
1457    /// together, so the fitted probabilities after label alignment are
1458    /// identical, while REML still selects genuinely heterogeneous per-class
1459    /// smoothness (a wiggly class takes a small λ_c, an easy class shrinks its
1460    /// centered deviation toward the mean function).
1461    ///
1462    /// In stacked ALR coordinates `[β_0; …; β_{m−1}]` (`m = K−1`), class `c`'s
1463    /// centering row is `C_a = e_aᵀ − 𝟙ᵀ/K` for an active class and
1464    /// `C_ref = −𝟙ᵀ/K` for the reference, so spec `(t, c)` carries the PSD
1465    /// rank-`rank(S_t)` matrix `(C_cᵀC_c) ⊗ S_t`. With all `λ_{t,c}` equal the
1466    /// sum collapses exactly to the shared centered metric:
1467    /// `Σ_c C_cᵀC_c = I − J/K = M`, so this family strictly generalizes
1468    /// [`Self::centered_joint_penalty_specs`].
1469    ///
1470    /// `K = 2` is the degenerate case: `γ_ref = −γ_0`, both centered functions
1471    /// have identical wiggliness, and the two per-class metrics are
1472    /// proportional (only `λ_0 + λ_1` would be identified). The shared
1473    /// centered spec is the correct model there, so this builder returns it.
1474    /// The number of smoothing coordinates the OUTER search actually has.
1475    ///
1476    /// This is the length of the joint penalty spec list, and it is NOT
1477    /// `(K − 1) · n_penalties`. Under the equivariant carrier (#1587) each
1478    /// penalty component emits ONE spec PER CLASS (`s = t·K + c`), so a `K = 3`
1479    /// model carries `3·n_penalties` coordinates, not `2·n_penalties`; the
1480    /// `K ≤ 2` arm emits one shared centered spec per component. Any policy
1481    /// keyed on "how many ρ are there" — the exact-outer-curvature dimension
1482    /// gate, a cost estimate, a box — must read THIS and not the per-block
1483    /// count the pre-#1587 layout had, which is a different number for every
1484    /// `K > 2` model.
1485    ///
1486    /// Computed from the shapes alone, so a caller deciding a policy does not
1487    /// have to materialize `n_penalties · K` dense `(m·p)²` matrices to find out
1488    /// how many there will be.
1489    pub fn joint_smoothing_dimension(&self) -> usize {
1490        if self.total_classes <= 2 {
1491            self.penalties.len()
1492        } else {
1493            self.penalties.len().saturating_mul(self.total_classes)
1494        }
1495    }
1496
1497    pub fn equivariant_class_penalty_specs(
1498        &self,
1499    ) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
1500        let m = self.active_classes();
1501        let k = self.total_classes;
1502        let p = self.design.ncols();
1503        if k <= 2 {
1504            return self.centered_joint_penalty_specs();
1505        }
1506        let raw_total = m * p;
1507        self.validate_joint_seed_len(self.penalties.len() * k, "equivariant per-class")?;
1508        let mut specs = Vec::with_capacity(self.penalties.len() * k);
1509        for (t, pen) in self.penalties.iter().enumerate() {
1510            let s_t = pen.to_dense();
1511            // rank(C_cᵀC_c ⊗ S_t) = 1 · rank(S_t). The rank must agree with
1512            // the spectrum the joint-penalty validator measures (a structural
1513            // nullity claim understates the numerical nullity for
1514            // identifiability-absorbed smooths), so measure it with the
1515            // validator's own relative classification.
1516            let rank_s = measured_penalty_rank(&s_t)
1517                .map_err(|e| format!("multinomial equivariant penalty term {t}: {e}"))?;
1518            let nullspace_dim = raw_total - rank_s;
1519            for c in 0..k {
1520                // Centering row for class c over the m active coordinates.
1521                let row: Vec<f64> = (0..m)
1522                    .map(|b| {
1523                        let indicator = if c == b { 1.0 } else { 0.0 };
1524                        indicator - 1.0 / (k as f64)
1525                    })
1526                    .collect();
1527                let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
1528                for a in 0..m {
1529                    for b in 0..m {
1530                        let scale = row[a] * row[b];
1531                        if scale == 0.0 {
1532                            continue;
1533                        }
1534                        for i in 0..p {
1535                            for j in 0..p {
1536                                matrix[[a * p + i, b * p + j]] = scale * s_t[[i, j]];
1537                            }
1538                        }
1539                    }
1540                }
1541                specs.push(gam_problem::JointPenaltySpec {
1542                    label: Some(format!("multinomial_term_{t}_class_{c}")),
1543                    matrix,
1544                    initial_log_lambda: self.joint_seed(t * k + c),
1545                    nullspace_dim,
1546                    // #2579: the K per-class specs of term `t` are one term seen
1547                    // through K contrasts. A relabeling permutes them among
1548                    // themselves, so a consumer needing a reference-invariant
1549                    // per-term quantity aggregates over this group.
1550                    group: Some(t),
1551                });
1552            }
1553        }
1554        Ok(specs)
1555    }
1556
1557    /// Whether the solver's block specs describe the SAME geometry this family
1558    /// will assemble its joint workspace from.
1559    ///
1560    /// This predicate gates the three `*_available` capability answers below,
1561    /// and through them the solver's routing: `inner_blockwise_fit` reaches its
1562    /// coupled joint-Newton path when the family has an HVP workspace OR when
1563    /// there are at least two blocks.
1564    ///
1565    /// # What "workspace shape" is, and what it is not (gam#2612)
1566    ///
1567    /// The workspace assembles `X_aᵀ diag(w_ab) X_b` from the design, the row
1568    /// weights and the block count this family captured. Those are the only
1569    /// things it can disagree with a spec about, so those are the only things
1570    /// checked: row count, column count, offset length, block count, and the
1571    /// absence of a stacked design the workspace does not know how to index.
1572    ///
1573    /// It used to ALSO require `spec.penalties.len() == self.penalties.len()`
1574    /// and the same of `initial_log_lambdas`. Penalties are not part of the
1575    /// workspace's geometry — no penalty ever enters `X_aᵀ diag(w_ab) X_b`; the
1576    /// solver adds `s_lambdas` and the joint bundle itself, from the specs, on
1577    /// the other side of this call. The clause was a leftover from before
1578    /// gam#1587 moved this family's entire smoothing onto the JOINT penalty and
1579    /// made `build_block_specs` attach `penalties: Vec::new()` deliberately (see
1580    /// its comment: "The per-class blocks attach NO smooth penalty").
1581    ///
1582    /// So from #1587 onward the predicate was FALSE for every penalized
1583    /// multinomial — the family was declaring "I cannot serve a joint workspace"
1584    /// about the workspace it does in fact serve. For `K ≥ 3` that is invisible
1585    /// in the verdict (`specs.len() >= 2` reaches the joint path anyway) and
1586    /// costs only the workspace gradient/log-likelihood fast paths. For `K = 2`
1587    /// there is ONE block, so the stale clause was the whole routing decision:
1588    /// a two-class smooth multinomial fell onto the block-coordinate path,
1589    /// whose line search rejected every step, and the zero iterate was published
1590    /// as a converged mode — `edf_per_class = 4.09` with `β ≡ 0`, so every
1591    /// predicted probability was the uniform simplex.
1592    fn specs_match_workspace_shape(&self, specs: &[ParameterBlockSpec]) -> bool {
1593        let n = self.weights.len();
1594        let p = self.design.ncols();
1595        specs.len() == self.active_classes()
1596            && specs.iter().all(|spec| {
1597                spec.design.nrows() == n
1598                    && spec.design.ncols() == p
1599                    && spec.offset.len() == n
1600                    && spec.stacked_design.is_none()
1601                    && spec.stacked_offset.is_none()
1602            })
1603    }
1604
1605    /// Reshape the K-1 per-block `ParameterBlockState.eta` slices into the
1606    /// `(N, M)` matrix the likelihood expects. Validates lengths.
1607    fn collect_eta_matrix(
1608        &self,
1609        block_states: &[ParameterBlockState],
1610    ) -> Result<Array2<f64>, String> {
1611        let m = self.active_classes();
1612        validate_block_count::<String>("MultinomialFamily", m, block_states.len())?;
1613        let n = self.weights.len();
1614        let mut eta = Array2::<f64>::zeros((n, m));
1615        let eta_values = eta
1616            .as_slice_mut()
1617            .expect("fresh multinomial logits are contiguous");
1618        for (a, state) in block_states.iter().enumerate() {
1619            if state.eta.len() != n {
1620                return Err(format!(
1621                    "MultinomialFamily block {a} eta length {} != N = {n}",
1622                    state.eta.len()
1623                ));
1624            }
1625            let state_eta = state.eta.as_standard_layout();
1626            let state_values = state_eta
1627                .as_slice()
1628                .expect("standard-layout coefficient-block logits are contiguous");
1629            for row in 0..n {
1630                eta_values[row * m + a] = state_values[row];
1631            }
1632        }
1633        Ok(eta)
1634    }
1635
1636    /// Evaluate likelihood, per-row Fisher block, and per-row residual at
1637    /// the current `η`. Centralises the softmax-driven kernel so every
1638    /// downstream assembly (gradient, dense Hessian, directional derivative)
1639    /// reads from the same source.
1640    fn evaluate_row_kernels(
1641        &self,
1642        eta: ArrayView2<'_, f64>,
1643    ) -> Result<(f64, Array3<f64>, Array2<f64>), String> {
1644        let (log_lik, grad_eta_logl, fisher) = self
1645            .likelihood
1646            .value_gradient_hessian(eta, self.y_one_hot.view())
1647            .map_err(|error| error.to_string())?;
1648        Ok((log_lik, fisher, grad_eta_logl))
1649    }
1650
1651    /// Assemble the per-block gradient `∂(−log L)/∂β_a = X^T (p_a − y_a)`
1652    /// and the per-block dense Hessian `X^T diag_n(w_n · p_a(1 − p_a)) X`
1653    /// (= the block-diagonal piece of `−∇²log L`).
1654    ///
1655    /// Off-diagonal block coupling (`X^T diag_n(−w_n p_a p_b) X` for
1656    /// `a ≠ b`) lives in [`Self::exact_newton_joint_hessian`] — see the
1657    /// `ExactNewton` working-set contract on [`BlockWorkingSet`].
1658    fn assemble_block_diagonal_working_sets(
1659        &self,
1660        fisher: &Array3<f64>,
1661        grad_eta_logl: &Array2<f64>,
1662    ) -> Result<Vec<BlockWorkingSet>, String> {
1663        let n = self.weights.len();
1664        let p = self.design.ncols();
1665        let m = self.active_classes();
1666        let design = self.design.as_standard_layout();
1667        let design_values = design
1668            .as_slice()
1669            .expect("standard-layout multinomial design is contiguous");
1670        let fisher = fisher.as_standard_layout();
1671        let fisher_values = fisher
1672            .as_slice()
1673            .expect("standard-layout multinomial Fisher blocks are contiguous");
1674        let grad_eta_logl = grad_eta_logl.as_standard_layout();
1675        let grad_eta_values = grad_eta_logl
1676            .as_slice()
1677            .expect("standard-layout multinomial eta gradient is contiguous");
1678
1679        let mut sets = Vec::with_capacity(m);
1680        for a in 0..m {
1681            // Gradient of −log L wrt β_a: −X^T (y − p)_a = X^T (p − y)_a.
1682            let mut grad = Array1::<f64>::zeros(p);
1683            let grad_values = grad
1684                .as_slice_mut()
1685                .expect("fresh block gradient is contiguous");
1686            for i in 0..p {
1687                let mut acc = 0.0_f64;
1688                for row in 0..n {
1689                    acc += design_values[row * p + i] * (-grad_eta_values[row * m + a]);
1690                }
1691                grad_values[i] = acc;
1692            }
1693            // Dense block-diagonal Hessian: X^T diag(W_aa) X.
1694            let mut hess = Array2::<f64>::zeros((p, p));
1695            let hess_values = hess
1696                .as_slice_mut()
1697                .expect("fresh block Hessian is contiguous");
1698            for row in 0..n {
1699                let w_aa = fisher_values[(row * m + a) * m + a];
1700                if w_aa == 0.0 {
1701                    continue;
1702                }
1703                let design_row = &design_values[row * p..(row + 1) * p];
1704                for i in 0..p {
1705                    let xi = design_row[i];
1706                    if xi == 0.0 {
1707                        continue;
1708                    }
1709                    let scaled = w_aa * xi;
1710                    for j in 0..p {
1711                        hess_values[i * p + j] += scaled * design_row[j];
1712                    }
1713                }
1714            }
1715            // Symmetrise to cancel any accumulator drift.
1716            for i in 0..p {
1717                for j in (i + 1)..p {
1718                    let ij = i * p + j;
1719                    let ji = j * p + i;
1720                    let avg = 0.5 * (hess_values[ij] + hess_values[ji]);
1721                    hess_values[ij] = avg;
1722                    hess_values[ji] = avg;
1723                }
1724            }
1725            sets.push(BlockWorkingSet::ExactNewton {
1726                gradient: grad,
1727                hessian: SymmetricMatrix::Dense(hess),
1728            });
1729        }
1730        Ok(sets)
1731    }
1732
1733    /// Assemble the full joint stacked Hessian `H ∈ ℝ^{(M·P) × (M·P)}` via
1734    /// the canonical [`dense_block_xtwx`] helper. The ordering matches
1735    /// `flat[a · P + i] = β[i, a]` — output-major.
1736    fn assemble_joint_hessian(&self, fisher: &Array3<f64>) -> Result<Array2<f64>, String> {
1737        dense_block_xtwx(self.design.view(), fisher.view(), None)
1738            .map_err(|e| format!("MultinomialFamily joint Hessian assembly: {e}"))
1739    }
1740
1741    /// Stacked log-likelihood gradient `∂log L / ∂β_a = X^T (y − p)_a`,
1742    /// laid out in the same output-major flat order used by
1743    /// [`Self::assemble_joint_hessian`].
1744    fn assemble_joint_gradient(&self, grad_eta_logl: &Array2<f64>) -> Array1<f64> {
1745        let n = self.weights.len();
1746        let p = self.design.ncols();
1747        let m = self.active_classes();
1748        let design = self.design.as_standard_layout();
1749        let design_values = design
1750            .as_slice()
1751            .expect("standard-layout multinomial design is contiguous");
1752        let grad_eta_logl = grad_eta_logl.as_standard_layout();
1753        let grad_eta_values = grad_eta_logl
1754            .as_slice()
1755            .expect("standard-layout multinomial eta gradient is contiguous");
1756        let mut out = Array1::<f64>::zeros(m * p);
1757        let out_values = out
1758            .as_slice_mut()
1759            .expect("fresh joint gradient is contiguous");
1760        for a in 0..m {
1761            for i in 0..p {
1762                let mut acc = 0.0_f64;
1763                for row in 0..n {
1764                    acc += design_values[row * p + i] * grad_eta_values[row * m + a];
1765                }
1766                out_values[a * p + i] = acc;
1767            }
1768        }
1769        out
1770    }
1771
1772    /// Joint log-likelihood and stacked gradient evaluated from cached softmax
1773    /// probabilities, without re-collecting η or re-running the row kernels.
1774    ///
1775    /// `eta` and `probs_full` are the frozen row program's logits and `(N, K)`
1776    /// normalized masses. The value is re-evaluated through the canonical stable
1777    /// row expression (probabilities can underflow to exact zero, so taking their
1778    /// logarithm is not a valid tail representation); the gradient reuses the
1779    /// cached normalized masses. The gradient of `log L` wrt the active blocks is
1780    /// `∂log L/∂β_a = X^T (w ⊙ (y − p))_a`, laid out output-major to match
1781    /// [`Self::assemble_joint_hessian`]. Reused by the frozen-β workspace so the
1782    /// inner joint-Newton gradient load and line-search log-likelihood reads
1783    /// share the same cached probabilities as the matrix-free `H·v` contraction.
1784    fn joint_loglik_and_gradient_from_probs(
1785        &self,
1786        eta: ArrayView2<'_, f64>,
1787        probs_full: ArrayView2<'_, f64>,
1788    ) -> Result<(f64, Array1<f64>), String> {
1789        let n = self.weights.len();
1790        let p = self.design.ncols();
1791        let m = self.active_classes();
1792        let k = self.total_classes;
1793        assert_eq!(eta.dim(), (n, m));
1794        assert_eq!(probs_full.dim(), (n, k));
1795        let eta = eta.as_standard_layout();
1796        let eta_values = eta
1797            .as_slice()
1798            .expect("standard-layout multinomial logits are contiguous");
1799        let probs_full = probs_full.as_standard_layout();
1800        let probability_values = probs_full
1801            .as_slice()
1802            .expect("standard-layout multinomial probabilities are contiguous");
1803        let response = self.y_one_hot.as_standard_layout();
1804        let response_values = response
1805            .as_slice()
1806            .expect("standard-layout multinomial response is contiguous");
1807        let design = self.design.as_standard_layout();
1808        let design_values = design
1809            .as_slice()
1810            .expect("standard-layout multinomial design is contiguous");
1811        let mut log_lik = 0.0_f64;
1812        let mut eta_row = vec![0.0_f64; m];
1813        let mut response_row = vec![0.0_f64; k];
1814        for row in 0..n {
1815            let w = self.weights[row];
1816            if w == 0.0 {
1817                continue;
1818            }
1819            eta_row.copy_from_slice(&eta_values[row * m..(row + 1) * m]);
1820            response_row.copy_from_slice(&response_values[row * k..(row + 1) * k]);
1821            let program = MultinomialLogitRowProgram::new(&eta_row, &response_row, w)
1822                .map_err(|error| format!("invalid frozen multinomial row {row}: {error}"))?;
1823            log_lik -= program.negative_log_likelihood();
1824        }
1825        let mut grad = Array1::<f64>::zeros(m * p);
1826        let grad_values = grad
1827            .as_slice_mut()
1828            .expect("fresh joint gradient is contiguous");
1829        for a in 0..m {
1830            for i in 0..p {
1831                let mut acc = 0.0_f64;
1832                for row in 0..n {
1833                    let resid = self.weights[row]
1834                        * (response_values[row * k + a] - probability_values[row * k + a]);
1835                    acc += design_values[row * p + i] * resid;
1836                }
1837                grad_values[a * p + i] = acc;
1838            }
1839        }
1840        Ok((log_lik, grad))
1841    }
1842
1843    /// Apply a coefficient-space direction `d_β` to the design to obtain
1844    /// the per-row η-direction `(N × M)` matrix
1845    /// `d_η[n, a] = (X · d_β_a)[n]`.
1846    fn d_eta_from_d_beta(&self, d_beta_flat: &Array1<f64>) -> Result<Array2<f64>, String> {
1847        let p = self.design.ncols();
1848        let m = self.active_classes();
1849        let n = self.design.nrows();
1850        if d_beta_flat.len() != m * p {
1851            return Err(format!(
1852                "MultinomialFamily direction length {} != (K-1)·P = {}",
1853                d_beta_flat.len(),
1854                m * p
1855            ));
1856        }
1857        let design = self.design.as_standard_layout();
1858        let design_values = design
1859            .as_slice()
1860            .expect("standard-layout multinomial design is contiguous");
1861        let d_beta = d_beta_flat.as_standard_layout();
1862        let d_beta_values = d_beta
1863            .as_slice()
1864            .expect("standard-layout multinomial direction is contiguous");
1865        let mut d_eta = Array2::<f64>::zeros((n, m));
1866        let d_eta_values = d_eta
1867            .as_slice_mut()
1868            .expect("fresh multinomial eta direction is contiguous");
1869        for a in 0..m {
1870            for row in 0..n {
1871                let mut acc = 0.0_f64;
1872                for i in 0..p {
1873                    acc += design_values[row * p + i] * d_beta_values[a * p + i];
1874                }
1875                d_eta_values[row * m + a] = acc;
1876            }
1877        }
1878        Ok(d_eta)
1879    }
1880
1881    /// Compute the per-row softmax probabilities `p[n, c]` over all `K`
1882    /// classes. The reference class column lives at index `K − 1`.
1883    fn row_probabilities(&self, eta: ArrayView2<'_, f64>) -> Array2<f64> {
1884        self.likelihood.probabilities(eta)
1885    }
1886
1887    /// Matrix-free joint Hessian–vector product `H·v` for the softmax
1888    /// curvature `H = block( X^T W(β) X )`, written into `out` in
1889    /// `O(N·(K-1)·P)` without ever materialising the
1890    /// `(K-1)P × (K-1)P` dense Hessian.
1891    ///
1892    /// Mathematically identical to
1893    /// `assemble_joint_hessian(hess_block(η)).dot(v)`; the result agrees with
1894    /// the dense path up to floating-point reassociation of the row sums. The
1895    /// contraction exploits the rank structure of the per-row Fisher block
1896    /// `W_{n,a,b} = w_n (δ_ab p_{n,a} − p_{n,a} p_{n,b})` so the off-diagonal
1897    /// `−p_a p_b` coupling never materialises:
1898    ///
1899    /// ```text
1900    ///   (X v_b)_n      = Σ_j X_{n,j} v_{b·P+j}            [step 1]
1901    ///   s_n            = Σ_b p_{n,b} (X v_b)_n            [step 2a]
1902    ///   r_{n,a}        = w_n p_{n,a} ( (X v_a)_n − s_n )  [step 2b]
1903    ///   (H v)_{a·P+i}  = Σ_n X_{n,i} r_{n,a}              [step 3]
1904    /// ```
1905    ///
1906    /// `probs_full` is the cached `(N, K)` softmax probability matrix at the
1907    /// frozen β; only the `K − 1` active columns are read (the reference
1908    /// column `K − 1` contributes nothing because `η_{K-1} ≡ 0` is constant
1909    /// in β). `out` must already be length `(K-1)·P`; it is overwritten.
1910    fn hessian_matvec_into_with_probs(
1911        &self,
1912        probs_full: ArrayView2<'_, f64>,
1913        v: &Array1<f64>,
1914        out: &mut Array1<f64>,
1915    ) -> Result<(), String> {
1916        let p = self.design.ncols();
1917        let m = self.active_classes();
1918        let n = self.weights.len();
1919        let total = m * p;
1920        if v.len() != total {
1921            return Err(format!(
1922                "MultinomialHessianWorkspace::hessian_matvec: v len {} != (K-1)·P = {total}",
1923                v.len()
1924            ));
1925        }
1926        if out.len() != total {
1927            return Err(format!(
1928                "MultinomialHessianWorkspace::hessian_matvec: out len {} != (K-1)·P = {total}",
1929                out.len()
1930            ));
1931        }
1932        out.fill(0.0);
1933        let design = self.design.as_standard_layout();
1934        let design_values = design
1935            .as_slice()
1936            .expect("standard-layout multinomial design is contiguous");
1937        let probs_full = probs_full.as_standard_layout();
1938        let probability_values = probs_full
1939            .as_slice()
1940            .expect("standard-layout multinomial probabilities are contiguous");
1941        let v = v.as_standard_layout();
1942        let v_values = v
1943            .as_slice()
1944            .expect("standard-layout Hessian direction is contiguous");
1945        let out_values = out
1946            .as_slice_mut()
1947            .expect("standard-layout Hessian output is contiguous");
1948        let mut xv = vec![0.0_f64; m];
1949        for row in 0..n {
1950            let w = self.weights[row];
1951            if w == 0.0 {
1952                continue;
1953            }
1954            // step 1 + 2a: per-row directional η `(X v_b)_n` and the
1955            // probability-weighted scalar `s_n = Σ_b p_{n,b} (X v_b)_n`.
1956            let mut s = 0.0_f64;
1957            for b in 0..m {
1958                let mut acc = 0.0_f64;
1959                for j in 0..p {
1960                    acc += design_values[row * p + j] * v_values[b * p + j];
1961                }
1962                xv[b] = acc;
1963                s += probability_values[row * self.total_classes + b] * acc;
1964            }
1965            // step 2b + 3: the row residual `r_{n,a}` scattered through Xᵀ.
1966            for a in 0..m {
1967                let r = w * probability_values[row * self.total_classes + a] * (xv[a] - s);
1968                if r == 0.0 {
1969                    continue;
1970                }
1971                let base = a * p;
1972                for i in 0..p {
1973                    out_values[base + i] += design_values[row * p + i] * r;
1974                }
1975            }
1976        }
1977        Ok(())
1978    }
1979
1980    /// Matrix-free diagonal of the joint softmax Hessian. The only non-zero
1981    /// contribution to entry `(a·P+i, a·P+i)` is the block-diagonal Fisher
1982    /// term `Σ_n w_n p_{n,a}(1 − p_{n,a}) X_{n,i}²`; the off-diagonal
1983    /// `−p_a p_b` blocks never reach the diagonal. This is bit-identical to
1984    /// `assemble_joint_hessian(...).diag()` because (a) the per-row
1985    /// contribution `w · pa·(1−pa) · xi²` is built from the exact same
1986    /// scalar product chain `((w·pa·(1−pa)) · xi) · xi` that
1987    /// [`dense_block_xtwx`] flows through `scaled = wab · xi; acc += scaled · xj`
1988    /// at `i==j`, (b) the row sums are reduced through the same rayon
1989    /// `into_par_iter().fold(...).reduce(...)` partition tree, so the
1990    /// floating-point associativity of the parallel chunking matches the
1991    /// dense path bit-for-bit on identical input, and (c) the symmetrisation
1992    /// pass only averages strictly off-diagonal entries. Departing from
1993    /// (b) — e.g. a plain `for row in 0..n` serial loop here — would change
1994    /// the reduction order and break the bit-identical contract whenever
1995    /// rayon splits the dense path's row range into more than one chunk.
1996    fn hessian_diagonal_with_probs(&self, probs_full: ArrayView2<'_, f64>) -> Array1<f64> {
1997        let p = self.design.ncols();
1998        let m = self.active_classes();
1999        let n = self.weights.len();
2000        let dim = m * p;
2001        let design = self.design.view();
2002        gam_problem::outer_subsample::RowSet::All.par_reduce_fold(
2003            n,
2004            || Array1::<f64>::zeros(dim),
2005            |mut acc, row, _| {
2006                let w = self.weights[row];
2007                if w == 0.0 {
2008                    return acc;
2009                }
2010                for a in 0..m {
2011                    let pa = probs_full[[row, a]];
2012                    let waa = w * pa * (1.0 - pa);
2013                    if waa == 0.0 {
2014                        continue;
2015                    }
2016                    let base = a * p;
2017                    for i in 0..p {
2018                        let xi = design[[row, i]];
2019                        acc[base + i] += waa * xi * xi;
2020                    }
2021                }
2022                acc
2023            },
2024            |mut a, b| {
2025                a += &b;
2026                a
2027            },
2028        )
2029    }
2030
2031    /// Directional derivative of the per-row Fisher block along a
2032    /// coefficient direction `d_β` (length `(K-1)·P`). Returns the
2033    /// `(N, M, M)` jet `D_β H_row` whose `[n, a, b]` entry is
2034    /// `∂/∂t |_{t=0} { w_n · (δ_ab p_a(η + t d_η) − p_a(·) p_b(·)) }` with
2035    /// `d_η_n = X_n · d_β`.
2036    ///
2037    /// Using `∂p_a/∂η_c = p_a (δ_ac − p_c)` and writing `s_n :=
2038    /// Σ_c p_{n,c} · d_η_{n,c}` (the per-row probability-weighted direction
2039    /// scalar, restricted to active classes since the reference η is
2040    /// constant), the closed form is
2041    ///
2042    /// ```text
2043    ///   ∂p_{n,a}/∂t = p_{n,a} (d_η_{n,a} − s_n)
2044    /// ```
2045    ///
2046    /// and therefore
2047    ///
2048    /// ```text
2049    ///   D_β H_{n,a,b}[d_β] = w_n · ( δ_ab · ∂p_{n,a}/∂t
2050    ///                                 − ∂p_{n,a}/∂t · p_{n,b}
2051    ///                                 − p_{n,a} · ∂p_{n,b}/∂t )
2052    /// ```
2053    fn directional_fisher_jet(
2054        &self,
2055        eta: ArrayView2<'_, f64>,
2056        d_beta_flat: &Array1<f64>,
2057    ) -> Result<Array3<f64>, String> {
2058        let p = self.design.ncols();
2059        let m = self.active_classes();
2060        if d_beta_flat.len() != m * p {
2061            return Err(format!(
2062                "MultinomialFamily direction length {} != (K-1)·P = {}",
2063                d_beta_flat.len(),
2064                m * p
2065            ));
2066        }
2067        let probs_full = self.row_probabilities(eta);
2068        Ok(self.directional_fisher_jet_rows(probs_full.view(), d_beta_flat))
2069    }
2070
2071    /// Per-row `M×M` first-directional Fisher jet `Ĵ[row]` from frozen row
2072    /// probabilities (issue #932 matrix-free port).
2073    ///
2074    /// This is the *un-scattered* kernel of
2075    /// `assemble_directional_derivatives_from_probs`: it returns the
2076    /// per-row `M×M` block `Ĵ[row,a,b]` such that the dense directional
2077    /// derivative is exactly `B_d[(a,i),(b,j)] = Σ_row Ĵ[row,a,b]·X[row,i]·X[row,j]`.
2078    /// Its derivative arithmetic comes from [`softmax_fisher_perturbation`], the
2079    /// same normalized-softmax expression as every other live first/fourth-order
2080    /// consumer. Only the direction projection and X-factored scatter remain
2081    /// specialized; neither is calculus.
2082    fn directional_fisher_jet_rows(
2083        &self,
2084        probs_full: ArrayView2<'_, f64>,
2085        direction: &Array1<f64>,
2086    ) -> Array3<f64> {
2087        let n = self.weights.len();
2088        let p = self.design.ncols();
2089        let m = self.active_classes();
2090        let design = self.design.as_standard_layout();
2091        let design_values = design
2092            .as_slice()
2093            .expect("standard-layout multinomial design is contiguous");
2094        let direction = direction.as_standard_layout();
2095        let direction_values = direction
2096            .as_slice()
2097            .expect("owned coefficient direction is contiguous");
2098        let probs = probs_full.as_standard_layout();
2099        let probs_values = probs
2100            .as_slice()
2101            .expect("standard-layout multinomial probabilities are contiguous");
2102        let probability_columns = probs.ncols();
2103        let mut out = Array3::<f64>::zeros((n, m, m));
2104        let mut d_eta = vec![0.0_f64; m];
2105        let mut normalized = vec![0.0; m];
2106        let out_flat = out
2107            .as_slice_mut()
2108            .expect("owned Fisher jet must be contiguous");
2109        for row in 0..n {
2110            let w = self.weights[row];
2111            if w == 0.0 {
2112                continue;
2113            }
2114            for a in 0..m {
2115                let base = a * p;
2116                let mut eta_dir = 0.0_f64;
2117                for i in 0..p {
2118                    eta_dir += design_values[row * p + i] * direction_values[base + i];
2119                }
2120                d_eta[a] = eta_dir;
2121            }
2122            let row_start = row * m * m;
2123            softmax_fisher_perturbation::<OneSeed<0>>(
2124                m,
2125                w,
2126                |a| probs_values[row * probability_columns + a],
2127                |a| d_eta[a],
2128                |_| 0.0,
2129                &mut normalized,
2130                &mut out_flat[row_start..row_start + m * m],
2131            );
2132        }
2133        out
2134    }
2135
2136    /// Per-row `M×M` second-directional Fisher jet from frozen row probabilities
2137    /// (issue #932 matrix-free port). The un-scattered kernel of
2138    /// `assemble_second_directional_derivatives_from_probs`, with
2139    /// per-row arithmetic byte-identical to the dense assembly so the
2140    /// matrix-free `Fᵀ B_{uv} F` projection matches the dense path up to row-sum
2141    /// associativity.
2142    fn second_directional_fisher_jet_rows(
2143        &self,
2144        probs_full: ArrayView2<'_, f64>,
2145        u: &Array1<f64>,
2146        v: &Array1<f64>,
2147    ) -> Array3<f64> {
2148        let n = self.weights.len();
2149        let p = self.design.ncols();
2150        let m = self.active_classes();
2151        let design = self.design.as_standard_layout();
2152        let design_values = design
2153            .as_slice()
2154            .expect("standard-layout multinomial design is contiguous");
2155        let u = u.as_standard_layout();
2156        let u_values = u
2157            .as_slice()
2158            .expect("owned first coefficient direction is contiguous");
2159        let v = v.as_standard_layout();
2160        let v_values = v
2161            .as_slice()
2162            .expect("owned second coefficient direction is contiguous");
2163        let probs = probs_full.as_standard_layout();
2164        let probs_values = probs
2165            .as_slice()
2166            .expect("standard-layout multinomial probabilities are contiguous");
2167        let probability_columns = probs.ncols();
2168        let mut out = Array3::<f64>::zeros((n, m, m));
2169        let mut d_eta_u = vec![0.0_f64; m];
2170        let mut d_eta_v = vec![0.0_f64; m];
2171        let mut normalized = vec![[0.0; 3]; m];
2172        let out_flat = out
2173            .as_slice_mut()
2174            .expect("owned Fisher jet must be contiguous");
2175        for row in 0..n {
2176            let w = self.weights[row];
2177            if w == 0.0 {
2178                continue;
2179            }
2180            for a in 0..m {
2181                let base = a * p;
2182                let mut eta_u = 0.0_f64;
2183                let mut eta_v = 0.0_f64;
2184                for i in 0..p {
2185                    let x = design_values[row * p + i];
2186                    eta_u += x * u_values[base + i];
2187                    eta_v += x * v_values[base + i];
2188                }
2189                d_eta_u[a] = eta_u;
2190                d_eta_v[a] = eta_v;
2191            }
2192            let row_start = row * m * m;
2193            softmax_fisher_perturbation::<TwoSeed<0>>(
2194                m,
2195                w,
2196                |a| probs_values[row * probability_columns + a],
2197                |a| d_eta_u[a],
2198                |a| d_eta_v[a],
2199                &mut normalized,
2200                &mut out_flat[row_start..row_start + m * m],
2201            );
2202        }
2203        out
2204    }
2205
2206    /// Build the matrix-free first-directional joint-Hessian operator (#932).
2207    /// Validates the direction length identically to the dense assembly and
2208    /// stores only the per-row `M×M` jet, so the operator's `Fᵀ B_d F`
2209    /// projection reproduces the dense `DenseMatrixHyperOperator` value to
2210    /// floating-point reassociation.
2211    fn directional_hyper_operator(
2212        &self,
2213        probs_full: ArrayView2<'_, f64>,
2214        direction: &Array1<f64>,
2215        projection_cache: Arc<gam_runtime::resource::RayonSafeOnce<MultinomialClassProjection>>,
2216    ) -> Result<MultinomialDirectionalHyperOperator, String> {
2217        let dim = self.beta_flat_dim();
2218        if direction.len() != dim {
2219            return Err(format!(
2220                "MultinomialFamily matrix-free direction length {} != (K-1)·P = {dim}",
2221                direction.len()
2222            ));
2223        }
2224        Ok(MultinomialDirectionalHyperOperator {
2225            design: Arc::clone(&self.design),
2226            jet: self.directional_fisher_jet_rows(probs_full, direction),
2227            m: self.active_classes(),
2228            p: self.design.ncols(),
2229            projection_cache,
2230        })
2231    }
2232
2233    /// Build the matrix-free second-directional joint-Hessian operator (#932),
2234    /// the second-order sibling of [`Self::directional_hyper_operator`].
2235    fn second_directional_hyper_operator(
2236        &self,
2237        probs_full: ArrayView2<'_, f64>,
2238        u: &Array1<f64>,
2239        v: &Array1<f64>,
2240        projection_cache: Arc<gam_runtime::resource::RayonSafeOnce<MultinomialClassProjection>>,
2241    ) -> Result<MultinomialDirectionalHyperOperator, String> {
2242        let dim = self.beta_flat_dim();
2243        if u.len() != dim || v.len() != dim {
2244            return Err(format!(
2245                "MultinomialFamily matrix-free second-directional pair lengths {} and {} != (K-1)·P = {dim}",
2246                u.len(),
2247                v.len()
2248            ));
2249        }
2250        Ok(MultinomialDirectionalHyperOperator {
2251            design: Arc::clone(&self.design),
2252            jet: self.second_directional_fisher_jet_rows(probs_full, u, v),
2253            m: self.active_classes(),
2254            p: self.design.ncols(),
2255            projection_cache,
2256        })
2257    }
2258
2259    /// Second directional derivative kernel `D²_β H[d_u, d_v]`. Built by
2260    /// differentiating the first-order kernel along a second direction.
2261    ///
2262    /// Let `d_η^u = X d_u`, `d_η^v = X d_v`, `s^u = Σ_c p_c d_η^u_c`,
2263    /// `s^v = Σ_c p_c d_η^v_c`. Then
2264    ///
2265    /// ```text
2266    ///   ∂p_a/∂t_u = p_a (d_η^u_a − s^u)
2267    ///   ∂²p_a/∂t_u∂t_v = (∂p_a/∂t_v)(d_η^u_a − s^u)
2268    ///                  + p_a ( − ∂s^u/∂t_v )
2269    ///   ∂s^u/∂t_v = Σ_c (∂p_c/∂t_v) d_η^u_c
2270    /// ```
2271    ///
2272    /// We then propagate the same δ/outer-product structure as in
2273    /// [`Self::directional_fisher_jet`].
2274    fn second_directional_fisher_jet(
2275        &self,
2276        eta: ArrayView2<'_, f64>,
2277        d_beta_u: &Array1<f64>,
2278        d_beta_v: &Array1<f64>,
2279    ) -> Result<Array3<f64>, String> {
2280        let p = self.design.ncols();
2281        let m = self.active_classes();
2282        let dim = m * p;
2283        if d_beta_u.len() != dim || d_beta_v.len() != dim {
2284            return Err(format!(
2285                "MultinomialFamily second-directional pair lengths {} and {} != (K-1)·P = {dim}",
2286                d_beta_u.len(),
2287                d_beta_v.len()
2288            ));
2289        }
2290        let probs_full = self.row_probabilities(eta);
2291        Ok(self.second_directional_fisher_jet_rows(probs_full.view(), d_beta_u, d_beta_v))
2292    }
2293
2294    /// Exact one-pass assembly of
2295    /// `∇²_β tr(A H_Fisher(β))` for a fixed coefficient-space trace weight
2296    /// `A`.
2297    ///
2298    /// The generic Jeffreys completion asks for every pair
2299    /// `tr(A H''[e_u,e_v])`, which is `p_joint(p_joint+1)/2` dense Fisher builds.
2300    /// Softmax Fisher information has a row-factored representation that makes
2301    /// the same contraction one design Gram:
2302    ///
2303    /// ```text
2304    /// H_cd = Σ_r W_r[c,d] x_r x_rᵀ
2305    /// C_r[c,d] = x_rᵀ A_cd x_r
2306    /// tr(A H) = Σ_r <C_r, W_r>
2307    /// ∇²_β tr(A H) = X_blockᵀ { ∇²_η <C_r,W_r> } X_block.
2308    /// ```
2309    ///
2310    /// `softmax_fisher_perturbation::<TwoSeed>` is the authoritative second
2311    /// directional derivative of each row Fisher block. Contracting its
2312    /// `M×M` output with `C_r` for the `M(M+1)/2` eta-axis pairs produces one
2313    /// `(N,M,M)` row kernel, which [`dense_block_xtwx`] scatters once. This is
2314    /// algebraically identical to the defining pairwise contraction while
2315    /// changing the penguins Firth completion from thousands of dense Gram
2316    /// assemblies to one.
2317    fn contracted_fisher_trace_hessian(
2318        &self,
2319        eta: ArrayView2<'_, f64>,
2320        trace_weight: &Array2<f64>,
2321    ) -> Result<Array2<f64>, String> {
2322        let n = self.weights.len();
2323        let p = self.design.ncols();
2324        let m = self.active_classes();
2325        let dim = m * p;
2326        if trace_weight.dim() != (dim, dim) {
2327            return Err(format!(
2328                "multinomial contracted Fisher trace Hessian weight shape {:?} != ({dim}, {dim})",
2329                trace_weight.dim()
2330            ));
2331        }
2332        if trace_weight.iter().any(|value| !value.is_finite()) {
2333            return Err(
2334                "multinomial contracted Fisher trace Hessian weight is non-finite".to_string(),
2335            );
2336        }
2337        let probabilities = self.row_probabilities(eta);
2338        let design = self.design.view();
2339        let mut eta_hessian = Array3::<f64>::zeros((n, m, m));
2340        let mut coefficient_contraction = vec![0.0_f64; m * m];
2341        let mut normalized = vec![[0.0; 3]; m];
2342        let mut fisher_second = vec![0.0_f64; m * m];
2343        for row in 0..n {
2344            let row_weight = self.weights[row];
2345            if row_weight == 0.0 {
2346                continue;
2347            }
2348            coefficient_contraction.fill(0.0);
2349            for c in 0..m {
2350                let coefficient_row = c * p;
2351                for d in 0..m {
2352                    let coefficient_column = d * p;
2353                    let mut contraction = 0.0_f64;
2354                    for i in 0..p {
2355                        let x_i = design[[row, i]];
2356                        if x_i == 0.0 {
2357                            continue;
2358                        }
2359                        for j in 0..p {
2360                            contraction += x_i
2361                                * trace_weight[[coefficient_row + i, coefficient_column + j]]
2362                                * design[[row, j]];
2363                        }
2364                    }
2365                    coefficient_contraction[c * m + d] = contraction;
2366                }
2367            }
2368            for a in 0..m {
2369                for b in a..m {
2370                    normalized.fill([0.0; 3]);
2371                    fisher_second.fill(0.0);
2372                    softmax_fisher_perturbation::<TwoSeed<0>>(
2373                        m,
2374                        row_weight,
2375                        |class| probabilities[[row, class]],
2376                        |class| if class == a { 1.0 } else { 0.0 },
2377                        |class| if class == b { 1.0 } else { 0.0 },
2378                        &mut normalized,
2379                        &mut fisher_second,
2380                    );
2381                    let value = coefficient_contraction
2382                        .iter()
2383                        .zip(fisher_second.iter())
2384                        .map(|(&coefficient, &second)| coefficient * second)
2385                        .sum::<f64>();
2386                    eta_hessian[[row, a, b]] = value;
2387                    eta_hessian[[row, b, a]] = value;
2388                }
2389            }
2390        }
2391        dense_block_xtwx(self.design.view(), eta_hessian.view(), None)
2392            .map_err(|error| format!("multinomial contracted Fisher trace Hessian: {error}"))
2393    }
2394
2395    /// Materialize every canonical-axis derivative from its row-local
2396    /// `M × M` Fisher kernel using one third-moment GEMM per active class.
2397    ///
2398    /// For a fixed moving class `a`, every requested axis has the form
2399    ///
2400    /// ```text
2401    /// D H[e_(a,k)]_(c,i),(d,j)
2402    ///   = Σ_r J_a[r,c,d] X[r,k] X[r,i] X[r,j].
2403    /// ```
2404    ///
2405    /// The previous axis-parallel implementation evaluated that scalar nest
2406    /// literally, performing `M·P` bounds-checked `O(N·M²·P²)` sweeps. The
2407    /// arithmetic is a single matrix product after forming the row quadratics:
2408    ///
2409    /// ```text
2410    /// Q_a[r,(c,d,i,j)] = J_a[r,c,d] X[r,i] X[r,j]
2411    /// T_a = Xᵀ Q_a.
2412    /// ```
2413    ///
2414    /// `T_a[k,(c,d,i,j)]` is exactly the requested entry. This retains the full
2415    /// derivative object—no approximation or contraction—but routes its
2416    /// unavoidable third-moment work through the repository's SIMD/parallel
2417    /// matrix kernel. Peak scratch is `N·M²·P²` doubles, reused across `a`;
2418    /// the returned `M·P` matrices already require `M·P·(M·P)²` doubles.
2419    fn assemble_all_axis_derivatives_from_row_kernel(
2420        &self,
2421        mut fill_row_kernel: impl FnMut(usize, usize, &mut [f64]),
2422    ) -> Vec<Array2<f64>> {
2423        let n = self.weights.len();
2424        let p = self.design.ncols();
2425        let m = self.active_classes();
2426        let dim = m * p;
2427        let p_squared = p * p;
2428        let kernel_columns = m * m * p_squared;
2429        let design = self
2430            .design
2431            .as_slice()
2432            .expect("multinomial design is contiguous");
2433        let mut row_quadratics = Array2::<f64>::zeros((n, kernel_columns));
2434        let mut axes = Vec::with_capacity(dim);
2435        let mut row_kernel = vec![0.0_f64; m * m];
2436
2437        for moving_class in 0..m {
2438            row_quadratics.fill(0.0);
2439            let quadratics = row_quadratics
2440                .as_slice_mut()
2441                .expect("row-quadratic workspace is contiguous");
2442            for row in 0..n {
2443                if self.weights[row] == 0.0 {
2444                    continue;
2445                }
2446                fill_row_kernel(row, moving_class, &mut row_kernel);
2447                let x = &design[row * p..(row + 1) * p];
2448                let output = &mut quadratics[row * kernel_columns..(row + 1) * kernel_columns];
2449                for (class_pair, &kernel) in row_kernel.iter().enumerate() {
2450                    let block = &mut output[class_pair * p_squared..(class_pair + 1) * p_squared];
2451                    for (i, &x_i) in x.iter().enumerate() {
2452                        let block_row = &mut block[i * p..(i + 1) * p];
2453                        let scale = kernel * x_i;
2454                        for (entry, &x_j) in block_row.iter_mut().zip(x) {
2455                            *entry = scale * x_j;
2456                        }
2457                    }
2458                }
2459            }
2460
2461            let moments = fast_atb(self.design.as_ref(), &row_quadratics);
2462            let moments = moments
2463                .as_slice()
2464                .expect("third-moment GEMM output is contiguous");
2465            for moving_column in 0..p {
2466                let moment_row =
2467                    &moments[moving_column * kernel_columns..(moving_column + 1) * kernel_columns];
2468                let mut matrix = vec![0.0_f64; dim * dim];
2469                for c in 0..m {
2470                    for d in 0..m {
2471                        let class_pair = c * m + d;
2472                        let block =
2473                            &moment_row[class_pair * p_squared..(class_pair + 1) * p_squared];
2474                        for i in 0..p {
2475                            let output_start = (c * p + i) * dim + d * p;
2476                            matrix[output_start..output_start + p]
2477                                .copy_from_slice(&block[i * p..(i + 1) * p]);
2478                        }
2479                    }
2480                }
2481                for i in 0..dim {
2482                    for j in (i + 1)..dim {
2483                        let upper = i * dim + j;
2484                        let lower = j * dim + i;
2485                        let average = 0.5 * (matrix[upper] + matrix[lower]);
2486                        matrix[upper] = average;
2487                        matrix[lower] = average;
2488                    }
2489                }
2490                axes.push(
2491                    Array2::<f64>::from_shape_vec((dim, dim), matrix)
2492                        .expect("axis derivative buffer is dim·dim"),
2493                );
2494            }
2495        }
2496        axes
2497    }
2498
2499    /// Assemble the FULL set of canonical-axis joint-Hessian directional
2500    /// derivatives `{ Hdot[e_k] }` for every axis `k = a0·P + i0`, in a SINGLE
2501    /// shared softmax pass and one fused parallel row sweep — the exact value
2502    /// the Tier-B Jeffreys loop needs (it calls
2503    /// [`Self::exact_newton_joint_hessian_directional_derivative`] once per
2504    /// canonical axis at the SAME `β`).
2505    ///
2506    /// EXACTNESS. For the canonical axis `e_{(a0,i0)}` the design-projected
2507    /// η-direction is `d_η[row, b] = X[row, i0]·δ_{b,a0}` (only class `a0`'s
2508    /// channel moves, by `X[row, i0]`). Substituting into
2509    /// [`Self::directional_fisher_jet`] the per-row scalar collapses to
2510    /// `s = p_{a0}·X[row, i0]` and `∂p_c/∂t = p_c·X[row, i0]·(δ_{c,a0} − p_{a0})`,
2511    /// so the directional Fisher jet for this axis is `X[row, i0]·Ĵ_{a0}[row]`
2512    /// with `Ĵ_{a0}` the `M×M` per-row jet built from `dp̂_c = p_c (δ_{c,a0} −
2513    /// p_{a0})` (the `X[row, i0]` factor pulled out). Contracting through
2514    /// [`dense_block_xtwx`]'s `Σ_row J[c,d] X[row,i] X[row,j]` then gives
2515    ///
2516    /// ```text
2517    ///   Hdot[e_{(a0,i0)}][(c,i),(d,j)] = Σ_row Ĵ_{a0}[row,c,d] · X[row,i0] X[row,i] X[row,j].
2518    /// ```
2519    ///
2520    /// This is algebraically identical to the per-axis
2521    /// `directional_fisher_jet → dense_block_xtwx` path it replaces, up to the
2522    /// GEMM reduction order. [`Self::assemble_all_axis_derivatives_from_row_kernel`]
2523    /// forms the shared row quadratics once per moving class and closes every
2524    /// coefficient axis through one SIMD/parallel third-moment GEMM
2525    /// (#715/#722/#753/#2612 Firth grind).
2526    fn assemble_all_axis_directional_derivatives(
2527        &self,
2528        eta: ArrayView2<'_, f64>,
2529    ) -> Vec<Array2<f64>> {
2530        let m = self.active_classes();
2531        let probs_full = self.row_probabilities(eta);
2532        let mut normalized = vec![0.0; m];
2533        self.assemble_all_axis_derivatives_from_row_kernel(|row, moving_class, row_kernel| {
2534            softmax_fisher_perturbation::<OneSeed<0>>(
2535                m,
2536                self.weights[row],
2537                |class| probs_full[[row, class]],
2538                |class| if class == moving_class { 1.0 } else { 0.0 },
2539                |_| 0.0,
2540                &mut normalized,
2541                row_kernel,
2542            );
2543        })
2544    }
2545
2546    /// Assemble the FULL set of second-directional joint-Hessian derivatives
2547    /// `{ H²dot[δ, e_a] }` for a FIXED first direction `δ = d_beta_u` and every
2548    /// canonical second axis `a = a0·P + i0`, in a SINGLE shared softmax pass and
2549    /// one fused parallel row sweep — the value the Tier-B Jeffreys drift needs
2550    /// (it requests every canonical second axis at the same `β` and `δ`).
2551    ///
2552    /// EXACTNESS / FACTORISATION. For the canonical second axis `e_{(a0,i0)}` the
2553    /// design-projected v-direction is `d_η_v[row,b] = X[row,i0]·δ_{b,a0}`, so the
2554    /// per-row second-directional Fisher jet from
2555    /// [`Self::second_directional_fisher_jet`] factors as
2556    /// `X[row,i0]·Ĵ²_{a0,δ}[row]`, where the `X[row,i0]`-free per-row `M×M` jet
2557    /// `Ĵ²_{a0,δ}` is built from the SAME closed form with the `X[row,i0]` factor
2558    /// pulled out of the v-side quantities:
2559    /// ```text
2560    ///   s_u       = Σ_c p_c d_η^u_c                           (shared, δ-only)
2561    ///   dp_u[c]   = p_c (d_η^u_c − s_u)                        (shared, δ-only)
2562    ///   dp̂_v[c]   = p_c (δ_{c,a0} − p_{a0})                    (a0-only, X-free)
2563    ///   dŝ_u_dv   = Σ_c dp̂_v[c] d_η^u_c                        (a0,δ)
2564    ///   ddp̂[c]    = dp̂_v[c] (d_η^u_c − s_u) − p_c · dŝ_u_dv     (a0,δ)
2565    ///   Ĵ²[a,a]   = w ( ddp̂[a](1 − 2p_a) − 2 dp_u[a] dp̂_v[a] )
2566    ///   Ĵ²[a,b]   = −w ( ddp̂[a] p_b + dp_u[a] dp̂_v[b] + dp̂_v[a] dp_u[b] + p_a ddp̂[b] )
2567    /// ```
2568    /// Contracting through [`dense_block_xtwx`]'s `Σ_row J[c,d] X[row,i] X[row,j]`
2569    /// then gives
2570    /// ```text
2571    ///   H²dot[δ, e_{(a0,i0)}][(c,i),(d,j)] = Σ_row Ĵ²_{a0,δ}[row,c,d] · X[row,i0] X[row,i] X[row,j].
2572    /// ```
2573    /// This is algebraically identical to the per-axis
2574    /// `second_directional_fisher_jet → dense_block_xtwx` path the trait default
2575    /// runs, up to the GEMM reduction order. The shared third-moment assembly
2576    /// closes every `p = M·P` axis without the bounds-checked scalar scatter—the
2577    /// #1082/#979/#2612 outer-Jeffreys hotspot measured directly in production.
2578    fn assemble_all_axis_second_directional_derivatives(
2579        &self,
2580        eta: ArrayView2<'_, f64>,
2581        d_beta_u: &Array1<f64>,
2582    ) -> Result<Vec<Array2<f64>>, String> {
2583        let m = self.active_classes();
2584        let probs_full = self.row_probabilities(eta);
2585        let d_eta_u = self.d_eta_from_d_beta(d_beta_u)?;
2586        let mut normalized = vec![[0.0; 3]; m];
2587        Ok(
2588            self.assemble_all_axis_derivatives_from_row_kernel(|row, moving_class, row_kernel| {
2589                softmax_fisher_perturbation::<TwoSeed<0>>(
2590                    m,
2591                    self.weights[row],
2592                    |class| probs_full[[row, class]],
2593                    |class| d_eta_u[[row, class]],
2594                    |class| if class == moving_class { 1.0 } else { 0.0 },
2595                    &mut normalized,
2596                    row_kernel,
2597                );
2598            }),
2599        )
2600    }
2601
2602    /// Index of the single canonical axis `k` if `d_beta_flat` is the unit
2603    /// vector `e_k` (the Tier-B Jeffreys loop's request shape), else `None`.
2604    fn canonical_axis_index(&self, d_beta_flat: &Array1<f64>) -> Option<usize> {
2605        let mut axis: Option<usize> = None;
2606        for (k, &v) in d_beta_flat.iter().enumerate() {
2607            if v == 0.0 {
2608                continue;
2609            }
2610            if v != 1.0 || axis.is_some() {
2611                return None;
2612            }
2613            axis = Some(k);
2614        }
2615        axis
2616    }
2617
2618    /// Joint-Hessian directional derivative along a single canonical axis `e_k`,
2619    /// served from the shared per-`β` memo. The first axis requested at a fresh
2620    /// `β` assembles the WHOLE set in one softmax pass
2621    /// ([`Self::assemble_all_axis_directional_derivatives`]); every subsequent
2622    /// axis of that Jeffreys loop is a cache read — turning the term's `O(p)`
2623    /// redundant softmax/Gram rebuilds into a single shared pass (#715/#722).
2624    fn cached_axis_directional_derivative(
2625        &self,
2626        eta: ArrayView2<'_, f64>,
2627        axis: usize,
2628    ) -> Array2<f64> {
2629        let key = EtaFingerprint::of(eta);
2630        {
2631            let guard = self
2632                .axis_derivative_cache
2633                .lock()
2634                .expect("axis derivative cache mutex poisoned");
2635            if let Some(cache) = guard.as_ref()
2636                && cache.eta_key == key
2637            {
2638                return cache.derivatives[axis].clone();
2639            }
2640        }
2641        // Cache miss (fresh β): assemble the full axis set ONCE, store it, return
2642        // the requested axis. Assembly happens outside the lock so concurrent
2643        // requesters at the same β never block on each other's full sweep — a
2644        // redundant assemble is wasteful but never wrong (pure function of β).
2645        let derivatives = self.assemble_all_axis_directional_derivatives(eta);
2646        let result = derivatives[axis].clone();
2647        let mut guard = self
2648            .axis_derivative_cache
2649            .lock()
2650            .expect("axis derivative cache mutex poisoned");
2651        *guard = Some(AxisDerivativeCache {
2652            eta_key: key,
2653            derivatives,
2654        });
2655        result
2656    }
2657}
2658
2659impl CustomFamily for MultinomialFamily {
2660    fn joint_jeffreys_term_required(&self) -> bool {
2661        self.joint_jeffreys_term_strength > 0.0
2662    }
2663
2664    fn joint_jeffreys_term_strength(&self) -> f64 {
2665        self.joint_jeffreys_term_strength
2666    }
2667
2668    fn jeffreys_span_basis(&self) -> Result<Option<Array2<f64>>, String> {
2669        let Some(span) = self.joint_jeffreys_span.as_ref() else {
2670            return Ok(None);
2671        };
2672        let expected = self.beta_flat_dim();
2673        if span.nrows() != expected {
2674            return Err(format!(
2675                "multinomial measured Jeffreys span is {:?}, expected ({expected}, m)",
2676                span.dim()
2677            ));
2678        }
2679        Ok(Some(span.as_ref().clone()))
2680    }
2681
2682    fn coefficient_mode_homotopy_member(&self, progress: f64) -> Result<Option<Self>, String> {
2683        if !progress.is_finite() || !(0.0..=1.0).contains(&progress) {
2684            return Err(format!(
2685                "multinomial Jeffreys homotopy progress must lie in [0, 1], got {progress}"
2686            ));
2687        }
2688        if self.joint_jeffreys_term_strength == 0.0 {
2689            return Ok(None);
2690        }
2691        let mut member = self.clone();
2692        member.joint_jeffreys_term_strength = progress * self.joint_jeffreys_term_strength;
2693        Ok(Some(member))
2694    }
2695
2696    fn joint_penalty_specs(&self) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
2697        // The smoothing carrier is the permutation-equivariant per-class
2698        // centered penalty family: K per-term λ_{t,c} on the CENTERED class
2699        // functions γ_c (see `equivariant_class_penalty_specs`). This restores
2700        // the #1587 reference invariance the per-block ALR carrier broke
2701        // (relabeling the arbitrary baseline changed fitted probabilities)
2702        // while keeping the heterogeneous per-class smoothness #1855 requires
2703        // — per-CLASS λ on gauge-free functions, not per-contrast λ in the
2704        // reference-anchored frame. The per-class blocks attach NO smooth
2705        // penalty (see `build_block_specs`); double-carrying both would
2706        // penalize (I + Σ_c C_cᵀC_c) ⊗ S_t.
2707        self.equivariant_class_penalty_specs()
2708    }
2709
2710    /// The directions the multinomial's smoothing reaches, as one λ-free
2711    /// aggregate (#2612).
2712    ///
2713    /// Every joint spec is `(r_c r_cᵀ) ⊗ S_t` for a term `t` and a class
2714    /// contrast `r_c`, and `Σ_c r_c r_cᵀ` is exactly the centered class metric
2715    /// `M = I − J/K`, which is positive definite. So
2716    ///
2717    /// ```text
2718    ///     Σ_{t,c} (r_c r_cᵀ) ⊗ S_t  =  M ⊗ Σ_t S_t
2719    /// ```
2720    ///
2721    /// and, because `M` is PD, `ker(M ⊗ Σ_t S_t) = ℝ^{K−1} ⊗ ker(Σ_t S_t)` —
2722    /// the unpenalized columns of the shared design, replicated across the
2723    /// active classes. Every positive combination `Σ λ_{t,c} (r_c r_cᵀ) ⊗ S_t`
2724    /// has the SAME kernel, so this aggregate answers "which directions does the
2725    /// smoothing reach" without knowing a single λ; it is therefore constant in
2726    /// both `β` and `ρ`, which is what keeps the Jeffreys derivative tower valid
2727    /// unchanged.
2728    ///
2729    /// Built directly rather than by summing `equivariant_class_penalty_specs`,
2730    /// which materialises `K · T` dense `(K−1)P`-square matrices and is called
2731    /// on paths that run per inner-Newton cycle: this is one `P`-square sum and
2732    /// one `(K−1)`-square metric.
2733    fn jeffreys_span_aggregate_penalty(&self) -> Result<Option<Array2<f64>>, String> {
2734        if self.penalties.is_empty() {
2735            // No penalized component: nothing is reached, and the span stays the
2736            // full identifiable one exactly as before.
2737            return Ok(None);
2738        }
2739        let p = self.design.ncols();
2740        let m = self.active_classes();
2741        let mut term_sum = Array2::<f64>::zeros((p, p));
2742        for penalty in self.penalties.iter() {
2743            let dense = penalty.to_dense();
2744            if dense.dim() != (p, p) {
2745                return Err(format!(
2746                    "multinomial Jeffreys span aggregate: penalty component is {:?}, expected \
2747                     ({p}, {p})",
2748                    dense.dim()
2749                ));
2750            }
2751            term_sum += &dense;
2752        }
2753        let metric = centered_class_metric(m, self.total_classes);
2754        let mut aggregate = Array2::<f64>::zeros((m * p, m * p));
2755        for a in 0..m {
2756            for b in 0..m {
2757                let scale = metric[[a, b]];
2758                if scale == 0.0 {
2759                    continue;
2760                }
2761                for i in 0..p {
2762                    for j in 0..p {
2763                        aggregate[[a * p + i, b * p + j]] = scale * term_sum[[i, j]];
2764                    }
2765                }
2766            }
2767        }
2768        Ok(Some(aggregate))
2769    }
2770
2771    fn exact_newton_joint_hessian_beta_dependent(&self) -> bool {
2772        // H = X^T W(β) X with W depending on softmax probabilities of β.
2773        true
2774    }
2775
2776    fn inner_coefficient_objective_is_globally_convex(&self) -> bool {
2777        // The ordinary multinomial negative log-likelihood has Fisher Hessian
2778        // Xᵀ(diag(p) - ppᵀ)X ≽ 0, and every smoothing penalty is PSD. The
2779        // conditioning-gated Jeffreys log-determinant correction is not covered
2780        // by that convexity proof, so armed Firth fits retain the anchored
2781        // continuation while the unbiased separation probe bypasses it.
2782        self.joint_jeffreys_term_strength == 0.0
2783    }
2784
2785    fn pseudo_logdet_mode(&self) -> PseudoLogdetMode {
2786        // A Laplace approximation exists only at a strict local coefficient
2787        // mode. The reference-coded softmax gauge is removed structurally and
2788        // the inner KKT certificate is minted on that identifiable span, so its
2789        // accepted penalized Hessian must be positive definite. Price the exact
2790        // determinant there; never turn a singular mode or saddle into a
2791        // different objective through pseudo-spectral flooring.
2792        PseudoLogdetMode::PositiveDefinite
2793    }
2794
2795    fn has_explicit_joint_hessian(&self) -> bool {
2796        true
2797    }
2798
2799    fn requires_joint_outer_hyper_path(&self) -> bool {
2800        // Off-diagonal block coupling in H ⇒ blockwise diagonal surrogate
2801        // is mathematically invalid; force the joint exact path.
2802        true
2803    }
2804
2805    fn levenberg_on_ill_conditioning(&self) -> bool {
2806        // Engage the self-vanishing Levenberg–Marquardt damping on a FULL-RANK
2807        // but ILL-CONDITIONED penalized joint Hessian, not only on a
2808        // rank-deficient one.
2809        //
2810        // The penalized multinomial joint information is `H = Jᵀ W(β) J + S_λ`
2811        // with the softmax Fisher weight `W = diag(p) − p pᵀ`, which collapses
2812        // toward zero as fitted probabilities saturate near the simplex boundary
2813        // (the near-separating regime of small, well-fit categorical data — e.g.
2814        // the penguins `species ~ s(bill) + s(flipper) + body_mass` fit). There
2815        // `H` stays full rank but becomes ILL-CONDITIONED: range-space
2816        // curvature directions sit just above the rank cutoff. Undamped, the
2817        // range-restricted joint-Newton step takes an
2818        // enormous `component/λ` proposal on those near-singular modes, the trust
2819        // region clips it every cycle, and the stationarity residual along that
2820        // mode never settles — the inner solve oscillates and never certifies a
2821        // KKT point, so the outer REML startup seeds are all rejected (#715
2822        // real-data arm: "canonical-gauge null direction rejects all REML
2823        // seeds"; the macOS verdict's `phantom_multiplier_with_well_conditioned_H`
2824        // is the same near-singular-but-full-rank certificate failure).
2825        //
2826        // Because `μ ∝ ‖∇L − Sβ‖∞ → 0` at the fixed point, the damping only
2827        // shapes the trajectory (oscillation → bounded descent); the converged β,
2828        // the selected λ, and the KKT certificate are unchanged, so the
2829        // truth-recovery / match-or-beat bars are evaluated against the same
2830        // optimum and are never weakened.
2831        true
2832    }
2833
2834    fn inner_coefficient_hessian_hvp_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2835        self.specs_match_workspace_shape(specs)
2836    }
2837
2838    fn inner_joint_workspace_gradient_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2839        self.specs_match_workspace_shape(specs)
2840    }
2841
2842    fn inner_joint_workspace_log_likelihood_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2843        self.specs_match_workspace_shape(specs)
2844    }
2845
2846    fn coefficient_hessian_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
2847        // Every row contributes a rank-M outer product across the joint
2848        // (Σ p_b)² = (M · P)² space — the canonical joint-coupled cost.
2849        crate::custom_family::joint_coupled_coefficient_hessian_cost(
2850            self.weights.len() as u64,
2851            specs,
2852        )
2853    }
2854
2855    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
2856        let eta = self.collect_eta_matrix(block_states)?;
2857        let (log_lik, fisher, grad_eta_logl) = self.evaluate_row_kernels(eta.view())?;
2858        let working_sets = self.assemble_block_diagonal_working_sets(&fisher, &grad_eta_logl)?;
2859        Ok(FamilyEvaluation {
2860            log_likelihood: log_lik,
2861            blockworking_sets: working_sets,
2862        })
2863    }
2864
2865    fn log_likelihood_only(&self, block_states: &[ParameterBlockState]) -> Result<f64, String> {
2866        let eta = self.collect_eta_matrix(block_states)?;
2867        self.likelihood
2868            .log_lik(eta.view(), self.y_one_hot.view())
2869            .map_err(|error| error.to_string())
2870    }
2871
2872    fn exact_newton_joint_hessian(
2873        &self,
2874        block_states: &[ParameterBlockState],
2875    ) -> Result<Option<Array2<f64>>, String> {
2876        let eta = self.collect_eta_matrix(block_states)?;
2877        let (_, fisher, _) = self.evaluate_row_kernels(eta.view())?;
2878        let hessian = self.assemble_joint_hessian(&fisher)?;
2879        Ok(Some(hessian))
2880    }
2881
2882    fn exact_newton_joint_gradient_evaluation(
2883        &self,
2884        block_states: &[ParameterBlockState],
2885        specs: &[ParameterBlockSpec],
2886    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
2887        self.check_spec_coefficient_width(specs, "joint gradient")?;
2888        let eta = self.collect_eta_matrix(block_states)?;
2889        let (log_lik, grad_eta_logl) = self
2890            .likelihood
2891            .value_gradient(eta.view(), self.y_one_hot.view())
2892            .map_err(|error| error.to_string())?;
2893        let gradient = self.assemble_joint_gradient(&grad_eta_logl);
2894        Ok(Some(ExactNewtonJointGradientEvaluation {
2895            log_likelihood: log_lik,
2896            gradient,
2897        }))
2898    }
2899
2900    fn exact_newton_joint_hessian_workspace(
2901        &self,
2902        block_states: &[ParameterBlockState],
2903        specs: &[ParameterBlockSpec],
2904    ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
2905        self.check_spec_coefficient_width(specs, "joint Hessian workspace")?;
2906        // Freeze the per-row softmax probabilities once at construction: the
2907        // Fisher block H_{n,a,b} = w_n (δ_ab p_a − p_a p_b) is constant in the
2908        // matvec direction v, so every PCG H·v contraction reuses these probs
2909        // rather than re-running the softmax (matrix-free, O(N·K·P) per matvec
2910        // with no dense (M·P)² assembly — issue #347).
2911        let eta = self.collect_eta_matrix(block_states)?;
2912        let probs = self.row_probabilities(eta.view());
2913        Ok(Some(Arc::new(MultinomialHessianWorkspace {
2914            family: self.clone(),
2915            block_states: block_states.to_vec(),
2916            eta,
2917            probs,
2918            projection_cache: Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
2919        })))
2920    }
2921
2922    fn exact_newton_joint_hessian_directional_derivative(
2923        &self,
2924        block_states: &[ParameterBlockState],
2925        d_beta_flat: &Array1<f64>,
2926    ) -> Result<Option<Array2<f64>>, String> {
2927        let eta = self.collect_eta_matrix(block_states)?;
2928        if d_beta_flat.len() != self.beta_flat_dim() {
2929            return Err(format!(
2930                "MultinomialFamily direction length {} != (K-1)·P = {}",
2931                d_beta_flat.len(),
2932                self.beta_flat_dim()
2933            ));
2934        }
2935        // FAST PATH (the Tier-B Jeffreys/Firth loop): the term requests every
2936        // canonical axis `e_k` at the same β. Serve from the shared per-β memo so
2937        // the full set is assembled in ONE softmax pass and each axis is a cache
2938        // read, instead of `p` independent softmax + `dense_block_xtwx` rebuilds
2939        // (#715/#722/#753). The cached value is bit-faithful to the generic path
2940        // up to row-sum associativity.
2941        if let Some(axis) = self.canonical_axis_index(d_beta_flat) {
2942            return Ok(Some(
2943                self.cached_axis_directional_derivative(eta.view(), axis),
2944            ));
2945        }
2946        // General direction (e.g. the outer mode-response drift `Hdot[δ]`): the
2947        // exact per-direction jet → dense contraction.
2948        let dh_fisher = self.directional_fisher_jet(eta.view(), d_beta_flat)?;
2949        let dh = dense_block_xtwx(self.design.view(), dh_fisher.view(), None)
2950            .map_err(|e| format!("MultinomialFamily directional H assembly: {e}"))?;
2951        Ok(Some(dh))
2952    }
2953
2954    fn joint_jeffreys_information_directional_derivative_all_axes_with_specs(
2955        &self,
2956        block_states: &[ParameterBlockState],
2957        specs: &[ParameterBlockSpec],
2958    ) -> Result<Option<Vec<Array2<f64>>>, String> {
2959        // BATCHED all-axes fast path for the Tier-B Jeffreys/Firth loop
2960        // (#979). The generic trait default queries `Hdot[e_a]` `p = (K−1)·P`
2961        // separate times through the per-axis hook; each call takes the
2962        // axis-derivative cache Mutex and CLONES a full `dim×dim` matrix out
2963        // of the memo, and the default sweep runs SERIALLY. Multinomial
2964        // already assembles the WHOLE axis set from shared row kernels and a
2965        // third-moment GEMM. Wire that directly here: one batched build, returned
2966        // by move with no per-axis Mutex traffic or `dim×dim` clones. The
2967        // β-fixed `η` comes from `block_states` exactly as the per-axis
2968        // `exact_newton_joint_hessian_directional_derivative` does.
2969        let eta = self.collect_eta_matrix(block_states)?;
2970        let axes = self.assemble_all_axis_directional_derivatives(eta.view());
2971        // The caller indexes the returned Vec by canonical axis a ∈ 0..p, where
2972        // p = Σ spec.design.ncols() is the joint coefficient dimension across the
2973        // coupled softmax blocks. A mismatch means the derivative object is in a
2974        // different coordinate space and must be refused rather than indexed.
2975        let p: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
2976        if axes.len() != p {
2977            return Err(format!(
2978                "multinomial all-axes Jeffreys derivative produced {} axes but the block specs \
2979                 describe p={p} joint coefficients",
2980                axes.len(),
2981            ));
2982        }
2983        Ok(Some(axes))
2984    }
2985
2986    fn joint_jeffreys_information_second_directional_all_axes_with_specs(
2987        &self,
2988        block_states: &[ParameterBlockState],
2989        specs: &[ParameterBlockSpec],
2990        d_beta_u_flat: &Array1<f64>,
2991    ) -> Result<Option<Vec<Array2<f64>>>, String> {
2992        // BATCHED all-axes SECOND-directional fast path for the Tier-B Jeffreys
2993        // outer drift (#1082 / #979). The generic trait default queries
2994        // `H²dot[δ, e_a]` `p = (M·P)` separate times, each rebuilding the full
2995        // `O(n·M²·P²)` coupled Gram through `dense_block_xtwx` — the profile-pinned
2996        // outer hot spot (≈half the smooth-by-factor wall-clock; the drift batch
2997        // calls this once per mode-response direction). Multinomial forms the
2998        // `X[row,i0]`-factored row quadratics once and closes the WHOLE
2999        // second-axis set through the shared third-moment GEMM.
3000        let eta = self.collect_eta_matrix(block_states)?;
3001        let axes =
3002            self.assemble_all_axis_second_directional_derivatives(eta.view(), d_beta_u_flat)?;
3003        // Same canonical-axis contract as the first-directional batch: the caller
3004        // indexes by a ∈ 0..p with p = Σ spec.design.ncols(). A mismatch is a
3005        // coordinate-space defect, not a derivative batch the caller can use.
3006        let p: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
3007        if axes.len() != p {
3008            return Err(format!(
3009                "multinomial all-axes second Jeffreys derivative produced {} axes but the block \
3010                 specs describe p={p} joint coefficients",
3011                axes.len(),
3012            ));
3013        }
3014        Ok(Some(axes))
3015    }
3016
3017    fn joint_jeffreys_information_contracted_trace_hessian_with_specs(
3018        &self,
3019        block_states: &[ParameterBlockState],
3020        specs: &[ParameterBlockSpec],
3021        weight: &Array2<f64>,
3022    ) -> Result<Option<Array2<f64>>, String> {
3023        self.check_spec_coefficient_width(specs, "contracted Jeffreys-information trace Hessian")?;
3024        let eta = self.collect_eta_matrix(block_states)?;
3025        self.contracted_fisher_trace_hessian(eta.view(), weight)
3026            .map(Some)
3027    }
3028
3029    fn joint_jeffreys_information_contracted_trace_hessian_available(&self) -> bool {
3030        true
3031    }
3032
3033    fn exact_newton_joint_hessiansecond_directional_derivative(
3034        &self,
3035        block_states: &[ParameterBlockState],
3036        d_beta_u_flat: &Array1<f64>,
3037        d_beta_v_flat: &Array1<f64>,
3038    ) -> Result<Option<Array2<f64>>, String> {
3039        let eta = self.collect_eta_matrix(block_states)?;
3040        let d2h_fisher =
3041            self.second_directional_fisher_jet(eta.view(), d_beta_u_flat, d_beta_v_flat)?;
3042        let d2h = dense_block_xtwx(self.design.view(), d2h_fisher.view(), None)
3043            .map_err(|e| format!("MultinomialFamily second directional H assembly: {e}"))?;
3044        Ok(Some(d2h))
3045    }
3046}
3047
3048/// Workspace holding a frozen `(family, β)` snapshot from which the outer
3049/// exact-Newton driver pulls dense, matvec, and directional-derivative
3050/// views of the joint penalized Hessian.
3051///
3052/// Equivalent in spirit to `LatentHessianWorkspace` in
3053/// [`crate::survival::latent`]; the multinomial case keeps a
3054/// single workspace type because the family has no per-block
3055/// configuration to specialise on.
3056struct MultinomialHessianWorkspace {
3057    family: MultinomialFamily,
3058    block_states: Vec<ParameterBlockState>,
3059    /// Frozen active logits. Values cannot be reconstructed from probabilities
3060    /// after tail underflow, so the canonical row expression retains them for
3061    /// exact value/gradient workspace queries.
3062    eta: Array2<f64>,
3063    /// Per-row softmax probabilities `(N, K)` (including the reference column
3064    /// at index `K − 1`), frozen at the construction `β`. The Fisher block is
3065    /// a function of these alone, so the matrix-free `H·v` contraction reuses
3066    /// them across every PCG iteration (issue #347).
3067    probs: Array2<f64>,
3068    /// One exact class-projected factor shared by every first- and
3069    /// second-directional operator built from this frozen workspace. The outer
3070    /// trace kernels query all directional operators with the same factor, so
3071    /// `X·F_a` is workspace geometry, not direction-specific work.
3072    projection_cache: Arc<gam_runtime::resource::RayonSafeOnce<MultinomialClassProjection>>,
3073}
3074
3075impl ExactNewtonJointHessianWorkspace for MultinomialHessianWorkspace {
3076    fn warm_up_outer_caches_for_mode(
3077        &self,
3078        eval_mode: gam_problem::EvalMode,
3079    ) -> Result<(), String> {
3080        match eval_mode {
3081            gam_problem::EvalMode::ValueOnly
3082            | gam_problem::EvalMode::ValueAndGradient
3083            | gam_problem::EvalMode::ValueGradientHessian => Ok(()),
3084        }
3085    }
3086
3087    fn hessian_dense(&self) -> Result<Option<Array2<f64>>, String> {
3088        self.family.exact_newton_joint_hessian(&self.block_states)
3089    }
3090
3091    fn hessian_source_preference(&self) -> JointHessianSourcePreference {
3092        // The dense joint Hessian is `(K−1)P × (K−1)P` and the per-row Fisher
3093        // block is rank-M with a closed-form `H·v` contraction, so the
3094        // operator/PCG source is strictly cheaper than assembling and
3095        // factorizing the dense matrix every inner cycle. Prefer it so the
3096        // workspace-routed inner Newton never materializes the dense Hessian
3097        // (#714 / #722 inner cost).
3098        JointHessianSourcePreference::Operator
3099    }
3100
3101    fn joint_log_likelihood_evaluation(&self) -> Result<Option<f64>, String> {
3102        let (log_lik, _) = self
3103            .family
3104            .joint_loglik_and_gradient_from_probs(self.eta.view(), self.probs.view())?;
3105        Ok(Some(log_lik))
3106    }
3107
3108    fn joint_gradient_evaluation(
3109        &self,
3110    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
3111        let (log_likelihood, gradient) = self
3112            .family
3113            .joint_loglik_and_gradient_from_probs(self.eta.view(), self.probs.view())?;
3114        Ok(Some(ExactNewtonJointGradientEvaluation {
3115            log_likelihood,
3116            gradient,
3117        }))
3118    }
3119
3120    fn hessian_matvec_available(&self) -> bool {
3121        true
3122    }
3123
3124    fn hessian_matvec(&self, v: &Array1<f64>) -> Result<Option<Array1<f64>>, String> {
3125        let mut out = Array1::<f64>::zeros(self.family.beta_flat_dim());
3126        self.family
3127            .hessian_matvec_into_with_probs(self.probs.view(), v, &mut out)?;
3128        Ok(Some(out))
3129    }
3130
3131    fn hessian_matvec_into(&self, v: &Array1<f64>, out: &mut Array1<f64>) -> Result<bool, String> {
3132        self.family
3133            .hessian_matvec_into_with_probs(self.probs.view(), v, out)?;
3134        Ok(true)
3135    }
3136
3137    fn hessian_diagonal(&self) -> Result<Option<Array1<f64>>, String> {
3138        Ok(Some(
3139            self.family.hessian_diagonal_with_probs(self.probs.view()),
3140        ))
3141    }
3142
3143    fn directional_derivative(
3144        &self,
3145        d_beta_flat: &Array1<f64>,
3146    ) -> Result<Option<Array2<f64>>, String> {
3147        self.family
3148            .exact_newton_joint_hessian_directional_derivative(&self.block_states, d_beta_flat)
3149    }
3150
3151    fn directional_derivative_operators(
3152        &self,
3153        d_beta_flats: &[Array1<f64>],
3154    ) -> Result<Vec<Option<Arc<dyn HyperOperator>>>, String> {
3155        // #932 cutover: the matrix-free `MultinomialDirectionalHyperOperator` is
3156        // the sole production path. It stores only the per-row `M×M` Fisher jet
3157        // and contracts against the design on the fly, never materializing the
3158        // dense `(M·P)×(M·P)` block matrix nor paying the generic dense
3159        // projection — the multinomial analogue of the primary-GLM matrix-free
3160        // `trace_projected_factor_all_axes_with_xf`.
3161        let probs = self.probs.view();
3162        d_beta_flats
3163            .par_iter()
3164            .map(|direction| {
3165                self.family
3166                    .directional_hyper_operator(
3167                        probs,
3168                        direction,
3169                        Arc::clone(&self.projection_cache),
3170                    )
3171                    .map(|op| Some(Arc::new(op) as Arc<dyn HyperOperator>))
3172            })
3173            .collect()
3174    }
3175
3176    fn second_directional_derivative(
3177        &self,
3178        d_beta_u: &Array1<f64>,
3179        d_beta_v: &Array1<f64>,
3180    ) -> Result<Option<Array2<f64>>, String> {
3181        self.family
3182            .exact_newton_joint_hessiansecond_directional_derivative(
3183                &self.block_states,
3184                d_beta_u,
3185                d_beta_v,
3186            )
3187    }
3188
3189    fn second_directional_derivative_operators(
3190        &self,
3191        d_beta_pairs: &[(Array1<f64>, Array1<f64>)],
3192    ) -> Result<Vec<Option<Arc<dyn HyperOperator>>>, String> {
3193        // #932 cutover: matrix-free second-directional operator is the sole
3194        // production path (see `directional_derivative_operators`).
3195        let probs = self.probs.view();
3196        d_beta_pairs
3197            .par_iter()
3198            .map(|(u, v)| {
3199                self.family
3200                    .second_directional_hyper_operator(
3201                        probs,
3202                        u,
3203                        v,
3204                        Arc::clone(&self.projection_cache),
3205                    )
3206                    .map(|op| Some(Arc::new(op) as Arc<dyn HyperOperator>))
3207            })
3208            .collect()
3209    }
3210}
3211
3212/// Exact key and value for the workspace-wide `X·F_a` projection.
3213///
3214/// The factor itself is retained rather than represented by a hash: trace
3215/// geometry is a numerical contract, so a cache hit must be collision-free.
3216/// `projected` is separately reference-counted because every directional
3217/// operator consumes it concurrently.
3218struct MultinomialClassProjection {
3219    factor: Array2<f64>,
3220    projected: Arc<Array2<f64>>,
3221}
3222
3223impl MultinomialClassProjection {
3224    fn matches(&self, factor: &Array2<f64>) -> bool {
3225        self.factor.dim() == factor.dim()
3226            && self
3227                .factor
3228                .iter()
3229                .zip(factor.iter())
3230                .all(|(&cached, &requested)| cached.to_bits() == requested.to_bits())
3231    }
3232}
3233
3234/// Matrix-free directional / second-directional joint-Hessian operator for the
3235/// multinomial-logit family (issue #932) — the sole production path for the
3236/// outer-Hessian directional terms (the dense `DenseMatrixHyperOperator`
3237/// assembly was cut over to this operator).
3238///
3239/// The former dense path (`assemble_directional_derivatives_from_probs` →
3240/// `DenseMatrixHyperOperator`, now retained only as the parity oracle's
3241/// reference) materializes the full `(M·P)×(M·P)` block matrix
3242///
3243/// ```text
3244///   B_d[(a,i),(b,j)] = Σ_row Ĵ[row,a,b] · X[row,i] · X[row,j]
3245/// ```
3246///
3247/// (an `O(N·M²·P²)` assembly) and then runs the generic dense projection
3248/// `Fᵀ B_d F` (an `O((M·P)²·rank)` GEMM pair). This operator instead stores only
3249/// the cheap per-row `M×M` Fisher jet `Ĵ` (`O(N·M²)`) and contracts against the
3250/// design on the fly — the multinomial analogue of the primary-GLM matrix-free
3251/// `ImplicitHyperOperator::trace_projected_factor_all_axes_with_xf`: precompute
3252/// `X·F` once per projection, contract per row over the `M×M` jet, and never
3253/// build the `(M·P)²` matrix or pay the dense projection. The projected matrix is
3254///
3255/// ```text
3256///   (Fᵀ B_d F)[k,l] = Σ_row Σ_{a,b} Ĵ[row,a,b] · g[row,a,k] · g[row,b,l],
3257///   where  g[row,a,k] = Σ_i X[row,i] · F[a·P+i, k].
3258/// ```
3259///
3260/// `is_implicit()` is `false` so the outer kernel treats this exactly like the
3261/// dense operator it replaces — the exact projected/trace path, never the
3262/// stochastic Hutch++ estimator (which would violate the ≤1e-10 contract).
3263struct MultinomialDirectionalHyperOperator {
3264    /// Shared `N×P` design (zero-copy clone of the family's `Arc`).
3265    design: Arc<Array2<f64>>,
3266    /// Per-row `M×M` Fisher-derivative jet `Ĵ[row]` (symmetric in `a,b`).
3267    jet: Array3<f64>,
3268    /// Active class count `M = K−1`.
3269    m: usize,
3270    /// Per-class feature count `P`.
3271    p: usize,
3272    /// Shared workspace cache for the factor projection. `RayonSafeOnce`
3273    /// computes outside its publication lock, so the nested BLAS/Rayon
3274    /// projection cannot deadlock a parallel operator batch.
3275    projection_cache: Arc<gam_runtime::resource::RayonSafeOnce<MultinomialClassProjection>>,
3276}
3277
3278impl MultinomialDirectionalHyperOperator {
3279    /// Compute `G_a = X F_a` for every active-class block `F_a` and stack the
3280    /// results class-major as an `(M*N) × rank` matrix.
3281    ///
3282    /// Every projection surface uses this same contraction.  Keeping it in a
3283    /// dense matrix multiply avoids repeating `N*M*rank` scalar dot products
3284    /// through bounds-checked ndarray indexing in debug/quality builds.
3285    fn compute_projected_design_by_class(&self, factor: &Array2<f64>) -> Array2<f64> {
3286        let dim = self.m * self.p;
3287        assert_eq!(factor.nrows(), dim);
3288        let n = self.design.nrows();
3289        let rank = factor.ncols();
3290        let mut projected = Array2::<f64>::zeros((self.m * n, rank));
3291        for class in 0..self.m {
3292            let factor_block = factor.slice(ndarray::s![class * self.p..(class + 1) * self.p, ..]);
3293            let class_projection = fast_ab(self.design.as_ref(), &factor_block);
3294            projected
3295                .slice_mut(ndarray::s![class * n..(class + 1) * n, ..])
3296                .assign(&class_projection);
3297        }
3298        projected
3299    }
3300
3301    /// Return the exact class-projected factor, sharing the workspace result
3302    /// when the requested factor is bit-identical. A later distinct factor is
3303    /// computed directly: a single frozen outer evaluation has one canonical
3304    /// projection factor, while retaining exact behavior for diagnostic calls
3305    /// that intentionally query several factors through the same workspace.
3306    fn projected_design_by_class(&self, factor: &Array2<f64>) -> Arc<Array2<f64>> {
3307        if let Some(cached) = self.projection_cache.get() {
3308            return if cached.matches(factor) {
3309                Arc::clone(&cached.projected)
3310            } else {
3311                Arc::new(self.compute_projected_design_by_class(factor))
3312            };
3313        }
3314
3315        let cached = self
3316            .projection_cache
3317            .get_or_compute(|| MultinomialClassProjection {
3318                factor: factor.clone(),
3319                projected: Arc::new(self.compute_projected_design_by_class(factor)),
3320            });
3321        if cached.matches(factor) {
3322            Arc::clone(&cached.projected)
3323        } else {
3324            Arc::new(self.compute_projected_design_by_class(factor))
3325        }
3326    }
3327
3328    /// Apply each row's `M × M` Fisher jet to the class axis of stacked
3329    /// projected designs.
3330    fn apply_jet_to_projected_design(&self, projected: &Array2<f64>) -> Array2<f64> {
3331        let n = self.design.nrows();
3332        let rank = projected.ncols();
3333        assert_eq!(projected.nrows(), self.m * n);
3334        let projected_values = projected
3335            .as_slice()
3336            .expect("class-projected design is standard-layout");
3337        let jet_values = self
3338            .jet
3339            .as_slice()
3340            .expect("directional Fisher jet is standard-layout");
3341        let mut weighted = Array2::<f64>::zeros(projected.raw_dim());
3342        let weighted_values = weighted
3343            .as_slice_mut()
3344            .expect("weighted class-projected design is standard-layout");
3345
3346        for class in 0..self.m {
3347            for row in 0..n {
3348                let target = (class * n + row) * rank;
3349                for source_class in 0..self.m {
3350                    let weight = jet_values[(row * self.m + class) * self.m + source_class];
3351                    let source = (source_class * n + row) * rank;
3352                    for column in 0..rank {
3353                        weighted_values[target + column] +=
3354                            weight * projected_values[source + column];
3355                    }
3356                }
3357            }
3358        }
3359        weighted
3360    }
3361}
3362
3363impl HyperOperator for MultinomialDirectionalHyperOperator {
3364    fn dim(&self) -> usize {
3365        self.m * self.p
3366    }
3367
3368    fn as_any(&self) -> &(dyn std::any::Any + 'static) {
3369        self
3370    }
3371
3372    fn is_implicit(&self) -> bool {
3373        false
3374    }
3375
3376    fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64> {
3377        let dim = self.m * self.p;
3378        assert_eq!(v.len(), dim);
3379        let n = self.design.nrows();
3380        let (m, p) = (self.m, self.p);
3381        let design = self.design.as_standard_layout();
3382        let design_values = design
3383            .as_slice()
3384            .expect("standard-layout multinomial design is contiguous");
3385        let jet = self.jet.as_standard_layout();
3386        let jet_values = jet
3387            .as_slice()
3388            .expect("standard-layout directional Fisher jet is contiguous");
3389        let v = v.as_standard_layout();
3390        let v_values = v
3391            .as_slice()
3392            .expect("standard-layout directional-operator input is contiguous");
3393        let mut out = Array1::<f64>::zeros(dim);
3394        let out_values = out
3395            .as_slice_mut()
3396            .expect("fresh directional-operator output is contiguous");
3397        let mut t = vec![0.0_f64; m];
3398        let mut u = vec![0.0_f64; m];
3399        for row in 0..n {
3400            // t[b] = X[row] · v_block_b
3401            for b in 0..m {
3402                let base = b * p;
3403                let mut acc = 0.0_f64;
3404                for i in 0..p {
3405                    acc += design_values[row * p + i] * v_values[base + i];
3406                }
3407                t[b] = acc;
3408            }
3409            // u[a] = Σ_b Ĵ[row,a,b] · t[b]
3410            for a in 0..m {
3411                let mut acc = 0.0_f64;
3412                for b in 0..m {
3413                    acc += jet_values[(row * m + a) * m + b] * t[b];
3414                }
3415                u[a] = acc;
3416            }
3417            // out[a·P+i] += u[a] · X[row,i]
3418            for a in 0..m {
3419                let ua = u[a];
3420                if ua == 0.0 {
3421                    continue;
3422                }
3423                let base = a * p;
3424                for i in 0..p {
3425                    out_values[base + i] += ua * design_values[row * p + i];
3426                }
3427            }
3428        }
3429        out
3430    }
3431
3432    fn projected_matrix(&self, factor: &Array2<f64>) -> Array2<f64> {
3433        let dim = self.m * self.p;
3434        assert_eq!(factor.nrows(), dim);
3435        // With class-major row stacking this is exactly
3436        //
3437        //   Σ_a G_aᵀ (Σ_b diag(J_ab) G_b) = Fᵀ B_d F.
3438        //
3439        // The former scalar loop recomputed every X·F block one row and one
3440        // rank coordinate at a time, then accumulated the rank² result through
3441        // bounds-checked indexing.  These two matrix products implement the
3442        // algebra stated in the operator's contract directly.
3443        let projected = self.projected_design_by_class(factor);
3444        let weighted = self.apply_jet_to_projected_design(projected.as_ref());
3445        fast_atb(projected.as_ref(), &weighted)
3446    }
3447
3448    fn trace_projected_factor(&self, factor: &Array2<f64>) -> f64 {
3449        // tr(Fᵀ B_d F) = Σ_row Σ_a,b J[row,a,b] <G[row,a],G[row,b]>.
3450        //
3451        // A trace has only `rank` diagonal terms.  Materializing the complete
3452        // `rank × rank` projection here made this nominally matrix-free
3453        // operation O(N*M*rank²), dominating the penguins quality fit.  The
3454        // direct contraction is exact and costs O(N*M²*rank).
3455        let dim = self.m * self.p;
3456        assert_eq!(factor.nrows(), dim);
3457        let projected = self.projected_design_by_class(factor);
3458        let projected_values = projected
3459            .as_slice()
3460            .expect("class-projected design is standard-layout");
3461        let jet_values = self
3462            .jet
3463            .as_slice()
3464            .expect("directional Fisher jet is standard-layout");
3465        let n = self.design.nrows();
3466        let rank = factor.ncols();
3467        let mut trace = 0.0_f64;
3468        for row in 0..n {
3469            for class in 0..self.m {
3470                let left = (class * n + row) * rank;
3471                for source_class in 0..self.m {
3472                    let right = (source_class * n + row) * rank;
3473                    let mut dot = 0.0_f64;
3474                    for column in 0..rank {
3475                        dot += projected_values[left + column] * projected_values[right + column];
3476                    }
3477                    trace += jet_values[(row * self.m + class) * self.m + source_class] * dot;
3478                }
3479            }
3480        }
3481        trace
3482    }
3483
3484    fn to_dense(&self) -> Array2<f64> {
3485        // B_d[(a,i),(b,j)] = Σ_row Ĵ[row,a,b] · X[row,i] · X[row,j].
3486        let dim = self.m * self.p;
3487        let design = self.design.view();
3488        let n = design.nrows();
3489        let (m, p) = (self.m, self.p);
3490        let mut out = Array2::<f64>::zeros((dim, dim));
3491        for row in 0..n {
3492            for a in 0..m {
3493                for b in 0..m {
3494                    let jab = self.jet[[row, a, b]];
3495                    if jab == 0.0 {
3496                        continue;
3497                    }
3498                    let ra = a * p;
3499                    let rb = b * p;
3500                    for i in 0..p {
3501                        let xi = design[[row, i]];
3502                        if xi == 0.0 {
3503                            continue;
3504                        }
3505                        let scaled = jab * xi;
3506                        for j in 0..p {
3507                            out[[ra + i, rb + j]] += scaled * design[[row, j]];
3508                        }
3509                    }
3510                }
3511            }
3512        }
3513        out
3514    }
3515}
3516
3517#[cfg(test)]
3518mod tests {
3519    //! Identifiability + reference-class-gauge audit.
3520    //!
3521    //! The reference class `K − 1` carries `η ≡ 0` and is NOT represented
3522    //! as a parameter block — so the gauge is set entirely by the block
3523    //! layout. These tests pin three invariants the canonical
3524    //! [`gam_identifiability::canonical::canonicalize_for_identifiability`]
3525    //! step must preserve:
3526    //!
3527    //! 1. Block count `= K − 1` and block names `class_0 … class_{K-2}`.
3528    //! 2. Block ordering is class-order — never permuted.
3529    //! 3. `gauge_priority` is strictly decreasing in active-class index, so
3530    //!    the canonicaliser absorbs shared affine / null-space directions
3531    //!    onto the class farthest from the reference and the saved-model
3532    //!    `class_levels` order survives unchanged.
3533    use super::*;
3534    use gam_problem::DenseMatrixHyperOperator;
3535    use ndarray::array;
3536
3537    /// #932 production single-source parity: the live multinomial tower
3538    /// (`joint_loglik_and_gradient_from_probs`, `hessian_matvec_into_with_probs`,
3539    /// and the third/fourth `directional_fisher_jet_rows` /
3540    /// `second_directional_fisher_jet_rows` coefficient projections that the
3541    /// #1082 Jeffreys/Firth inner cycle runs) is pinned, by INVOKING PRODUCTION,
3542    /// against the universal gam-math jet — and against an independent
3543    /// finite-difference witness that never touches the jet.
3544    ///
3545    /// Production differentiates the one normalized-softmax Fisher expression
3546    /// through compact nilpotent channels; only the X-factored coefficient-space
3547    /// scatter is specialized. This module makes any dropped or sign-flipped
3548    /// coefficient loud without retaining separate production calculus.
3549    mod jet_single_source_932 {
3550        use super::*;
3551        use gam_math::jet_tower::{
3552            program_fourth_contracted, program_row_kernel, program_third_contracted,
3553        };
3554        use std::sync::Arc;
3555
3556        /// Build a single-row `K = M + 1` family with the design collapsed to the
3557        /// `1×1` identity (`P = 1`, `X = [[1.0]]`), so the coefficient-space
3558        /// directions the production kernels consume ARE the η-space directions —
3559        /// letting the per-row β-space kernels be compared to the jet's η-space
3560        /// contractions with no design projection in the way.
3561        fn single_row_family(obs: usize, w: f64, k: usize) -> MultinomialFamily {
3562            let mut y = Array2::<f64>::zeros((1, k));
3563            y[[0, obs]] = 1.0;
3564            let design = Arc::new(array![[1.0_f64]]);
3565            MultinomialFamily::new(y, array![w], k, design, Arc::new(Vec::new()))
3566                .expect("single-row multinomial family")
3567        }
3568
3569        fn single_row_family_response(response: &[f64], w: f64) -> MultinomialFamily {
3570            let y = Array2::from_shape_vec((1, response.len()), response.to_vec())
3571                .expect("single-row simplex response");
3572            MultinomialFamily::new(
3573                y,
3574                array![w],
3575                response.len(),
3576                Arc::new(array![[1.0_f64]]),
3577                Arc::new(Vec::new()),
3578            )
3579            .expect("single-row multinomial family with simplex response")
3580        }
3581
3582        /// Deterministic LCG (NO `rand`, NO clock seeding — #932 rules).
3583        struct Lcg(u64);
3584        impl Lcg {
3585            fn f64(&mut self) -> f64 {
3586                self.0 = self
3587                    .0
3588                    .wrapping_mul(6364136223846793005)
3589                    .wrapping_add(1442695040888963407);
3590                ((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
3591            }
3592            fn uniform(&mut self, lo: f64, hi: f64) -> f64 {
3593                lo + (hi - lo) * self.f64()
3594            }
3595        }
3596
3597        const JET_TOL: f64 = 1e-9;
3598
3599        fn close(a: f64, b: f64, tol: f64, label: &str) {
3600            let band = tol + tol * a.abs().max(b.abs());
3601            assert!(
3602                (a - b).abs() <= band,
3603                "{label}: {a:+.15e} vs {b:+.15e} (|Δ|={:.3e} band {band:.3e})",
3604                (a - b).abs()
3605            );
3606        }
3607
3608        /// Row probabilities over the `M` ACTIVE classes at raw η (reference class
3609        /// dropped), via the production softmax pass.
3610        fn active_probs<const M: usize>(
3611            family: &MultinomialFamily,
3612            eta: &[f64; M],
3613        ) -> ndarray::Array2<f64> {
3614            let eta2 = Array2::<f64>::from_shape_vec((1, M), eta.to_vec()).expect("eta (1,M)");
3615            family.row_probabilities(eta2.view())
3616        }
3617
3618        /// Production third `∂_dir H` at η: the per-row `M×M` Fisher jet, evaluated
3619        /// by the LIVE `directional_fisher_jet_rows`.
3620        fn prod_third<const M: usize>(
3621            family: &MultinomialFamily,
3622            eta: &[f64; M],
3623            dir: &[f64; M],
3624        ) -> [[f64; M]; M] {
3625            let probs = active_probs(family, eta);
3626            let d = Array1::from(dir.to_vec());
3627            let j = family.directional_fisher_jet_rows(probs.view(), &d);
3628            std::array::from_fn(|a| std::array::from_fn(|b| j[[0, a, b]]))
3629        }
3630
3631        /// Production fourth `∂_u ∂_v H` at η via the LIVE
3632        /// `second_directional_fisher_jet_rows`.
3633        fn prod_fourth<const M: usize>(
3634            family: &MultinomialFamily,
3635            eta: &[f64; M],
3636            u: &[f64; M],
3637            v: &[f64; M],
3638        ) -> [[f64; M]; M] {
3639            let probs = active_probs(family, eta);
3640            let ua = Array1::from(u.to_vec());
3641            let va = Array1::from(v.to_vec());
3642            let j = family.second_directional_fisher_jet_rows(probs.view(), &ua, &va);
3643            std::array::from_fn(|a| std::array::from_fn(|b| j[[0, a, b]]))
3644        }
3645
3646        /// Production Hessian block at η via the LIVE `hessian_matvec_into_with_probs`
3647        /// (column extraction against the `M` unit directions).
3648        fn prod_hessian<const M: usize>(
3649            family: &MultinomialFamily,
3650            eta: &[f64; M],
3651        ) -> [[f64; M]; M] {
3652            let probs = active_probs(family, eta);
3653            let mut h = [[0.0_f64; M]; M];
3654            for col in 0..M {
3655                let mut e = Array1::<f64>::zeros(M);
3656                e[col] = 1.0;
3657                let mut out = Array1::<f64>::zeros(M);
3658                family
3659                    .hessian_matvec_into_with_probs(probs.view(), &e, &mut out)
3660                    .expect("prod hessian matvec");
3661                for row in 0..M {
3662                    h[row][col] = out[row];
3663                }
3664            }
3665            h
3666        }
3667
3668        fn run_parity<const M: usize>(seed: u64) {
3669            let mut rng = Lcg(seed);
3670            for trial in 0..24 {
3671                let eta: [f64; M] = std::array::from_fn(|_| rng.uniform(-2.0, 2.0));
3672                let obs = trial % (M + 1);
3673                let w = rng.uniform(0.25, 2.5);
3674                let family = single_row_family(obs, w, M + 1);
3675                let mut response = vec![0.0; M + 1];
3676                response[obs] = 1.0;
3677                let prog =
3678                    crate::multinomial_reml::MultinomialLogitRowProgram::new(&eta, &response, w)
3679                        .expect("valid multinomial row program");
3680
3681                // ── Jet ORACLE vs LIVE production (≤1e-9) ──────────────────────
3682                let (jet_v, jet_g, jet_h) =
3683                    program_row_kernel::<M, _>(&prog, 0).expect("jet row kernel");
3684
3685                // Value + gradient from the live log-lik assembler (NLL = −log_lik,
3686                // ∇NLL = −∇log_lik).
3687                let probs = active_probs(&family, &eta);
3688                let eta_matrix = Array2::from_shape_vec((1, M), eta.to_vec()).expect("eta matrix");
3689                let (log_lik, grad_ll) = family
3690                    .joint_loglik_and_gradient_from_probs(eta_matrix.view(), probs.view())
3691                    .expect("valid frozen multinomial row");
3692                close(
3693                    jet_v,
3694                    -log_lik,
3695                    JET_TOL,
3696                    &format!("M={M} trial {trial} value"),
3697                );
3698                for a in 0..M {
3699                    close(
3700                        jet_g[a],
3701                        -grad_ll[a],
3702                        JET_TOL,
3703                        &format!("M={M} trial {trial} grad[{a}]"),
3704                    );
3705                }
3706
3707                // Hessian block from the live matvec.
3708                let prod_h = prod_hessian(&family, &eta);
3709                for a in 0..M {
3710                    for b in 0..M {
3711                        close(
3712                            jet_h[a][b],
3713                            prod_h[a][b],
3714                            JET_TOL,
3715                            &format!("M={M} trial {trial} H[{a}][{b}]"),
3716                        );
3717                    }
3718                }
3719
3720                // Third + fourth directional Fisher jets from the live generated expression.
3721                let dir: [f64; M] = std::array::from_fn(|_| rng.uniform(-1.5, 1.5));
3722                let u: [f64; M] = std::array::from_fn(|_| rng.uniform(-1.5, 1.5));
3723                let jet_third = program_third_contracted(&prog, 0, &dir).expect("jet third");
3724                let prod_t3 = prod_third(&family, &eta, &dir);
3725                let jet_fourth = program_fourth_contracted(&prog, 0, &u, &dir).expect("jet fourth");
3726                let prod_t4 = prod_fourth(&family, &eta, &u, &dir);
3727                for a in 0..M {
3728                    for b in 0..M {
3729                        close(
3730                            jet_third[a][b],
3731                            prod_t3[a][b],
3732                            JET_TOL,
3733                            &format!("M={M} trial {trial} third[{a}][{b}]"),
3734                        );
3735                        close(
3736                            jet_fourth[a][b],
3737                            prod_t4[a][b],
3738                            JET_TOL,
3739                            &format!("M={M} trial {trial} fourth[{a}][{b}]"),
3740                        );
3741                    }
3742                }
3743
3744                // ── Independent FINITE-DIFFERENCE witness (NO jet) ─────────────
3745                // ∂_dir H via central difference of the live Hessian block.
3746                let h_fd = 1e-4;
3747                let eta_p: [f64; M] = std::array::from_fn(|a| eta[a] + h_fd * dir[a]);
3748                let eta_m: [f64; M] = std::array::from_fn(|a| eta[a] - h_fd * dir[a]);
3749                let hp = prod_hessian(&family, &eta_p);
3750                let hm = prod_hessian(&family, &eta_m);
3751                for a in 0..M {
3752                    for b in 0..M {
3753                        let fd = (hp[a][b] - hm[a][b]) / (2.0 * h_fd);
3754                        close(
3755                            prod_t3[a][b],
3756                            fd,
3757                            1e-6,
3758                            &format!("M={M} trial {trial} FD third[{a}][{b}]"),
3759                        );
3760                    }
3761                }
3762                // ∂_u of the live third (fixed second direction `dir`) via central
3763                // difference reproduces the live fourth.
3764                let t3_up = prod_third(&family, &eta_p_along(&eta, &u, h_fd), &dir);
3765                let t3_um = prod_third(&family, &eta_m_along(&eta, &u, h_fd), &dir);
3766                for a in 0..M {
3767                    for b in 0..M {
3768                        let fd = (t3_up[a][b] - t3_um[a][b]) / (2.0 * h_fd);
3769                        close(
3770                            prod_t4[a][b],
3771                            fd,
3772                            1e-6,
3773                            &format!("M={M} trial {trial} FD fourth[{a}][{b}]"),
3774                        );
3775                    }
3776                }
3777            }
3778        }
3779
3780        fn eta_p_along<const M: usize>(eta: &[f64; M], u: &[f64; M], h: f64) -> [f64; M] {
3781            std::array::from_fn(|a| eta[a] + h * u[a])
3782        }
3783        fn eta_m_along<const M: usize>(eta: &[f64; M], u: &[f64; M], h: f64) -> [f64; M] {
3784            std::array::from_fn(|a| eta[a] - h * u[a])
3785        }
3786
3787        /// The LIVE multinomial value / gradient / Hessian / third / fourth hand
3788        /// tower reproduces the universal gam-math jet at ≤1e-9, AND the live
3789        /// third/fourth reproduce an independent central-difference of the live
3790        /// lower order — for `M = 2` (K=3) and `M = 3` (K=4).
3791        #[test]
3792        fn multinomial_live_tower_matches_jet_and_fd() {
3793            run_parity::<2>(0x9322_2020_0710_face);
3794            run_parity::<3>(0x0bad_c0de_0710_2020);
3795        }
3796
3797        /// Saturated active/reference classes and label-smoothed targets all use
3798        /// the same centered semantic expression. This catches the former
3799        /// probability-clamp split: values remain exact after a probability has
3800        /// underflowed to zero, while V/G/H/t3/t4 stay finite and agree with the
3801        /// production structure-compiled schedules.
3802        #[test]
3803        fn multinomial_extreme_tails_share_one_stable_row_program_932() {
3804            const M: usize = 3;
3805            let cases = [
3806                ([1_000.0, -1_000.0, -750.0], [0.0, 0.0, 0.0, 1.0], 1.25),
3807                ([-1_000.0, -900.0, -800.0], [0.0, 0.0, 1.0, 0.0], 0.75),
3808                ([1_000.0, 1_000.0, -1_000.0], [0.2, 0.3, 0.1, 0.4], 2.0),
3809                ([f64::MAX, -f64::MAX, 0.0], [1.0, 0.0, 0.0, 0.0], 1.0),
3810                ([f64::MAX, -f64::MAX, 0.0], [0.0, 0.0, 0.0, 1.0], 0.0),
3811            ];
3812            let direction = [0.7, -0.4, 1.1];
3813            let direction_u = [-0.3, 0.9, 0.2];
3814
3815            for (case, (eta, response, weight)) in cases.into_iter().enumerate() {
3816                let program = MultinomialLogitRowProgram::new(&eta, &response, weight)
3817                    .expect("valid extreme-tail row program");
3818                let (canonical_value, canonical_gradient, canonical_hessian) =
3819                    program_row_kernel::<3, _>(&program, 0).expect("canonical extreme-tail V/G/H");
3820                let canonical_third = program_third_contracted(&program, 0, &direction)
3821                    .expect("canonical extreme-tail third");
3822                let canonical_fourth =
3823                    program_fourth_contracted(&program, 0, &direction_u, &direction)
3824                        .expect("canonical extreme-tail fourth");
3825
3826                assert!(canonical_value.is_finite(), "case {case} value");
3827                assert!(
3828                    canonical_gradient.iter().all(|value| value.is_finite()),
3829                    "case {case} gradient"
3830                );
3831                assert!(
3832                    canonical_hessian
3833                        .iter()
3834                        .flatten()
3835                        .all(|value| value.is_finite()),
3836                    "case {case} Hessian"
3837                );
3838                assert!(
3839                    canonical_third
3840                        .iter()
3841                        .flatten()
3842                        .all(|value| value.is_finite()),
3843                    "case {case} third"
3844                );
3845                assert!(
3846                    canonical_fourth
3847                        .iter()
3848                        .flatten()
3849                        .all(|value| value.is_finite()),
3850                    "case {case} fourth"
3851                );
3852
3853                let family = single_row_family_response(&response, weight);
3854                let eta_matrix =
3855                    Array2::from_shape_vec((1, M), eta.to_vec()).expect("tail eta matrix");
3856                let response_matrix = Array2::from_shape_vec((1, M + 1), response.to_vec())
3857                    .expect("tail response matrix");
3858                let (live_log_likelihood, live_gradient, live_hessian) = family
3859                    .likelihood
3860                    .value_gradient_hessian(eta_matrix.view(), response_matrix.view())
3861                    .expect("valid multinomial tail row");
3862                close(
3863                    canonical_value,
3864                    -live_log_likelihood,
3865                    1.0e-12,
3866                    &format!("tail case {case} value"),
3867                );
3868                for row in 0..M {
3869                    close(
3870                        canonical_gradient[row],
3871                        -live_gradient[[0, row]],
3872                        1.0e-12,
3873                        &format!("tail case {case} gradient[{row}]"),
3874                    );
3875                    for column in 0..M {
3876                        close(
3877                            canonical_hessian[row][column],
3878                            live_hessian[[0, row, column]],
3879                            1.0e-12,
3880                            &format!("tail case {case} Hessian[{row}][{column}]"),
3881                        );
3882                    }
3883                }
3884
3885                let live_third = prod_third(&family, &eta, &direction);
3886                let live_fourth = prod_fourth(&family, &eta, &direction_u, &direction);
3887                for row in 0..M {
3888                    for column in 0..M {
3889                        close(
3890                            canonical_third[row][column],
3891                            live_third[row][column],
3892                            1.0e-12,
3893                            &format!("tail case {case} third[{row}][{column}]"),
3894                        );
3895                        close(
3896                            canonical_fourth[row][column],
3897                            live_fourth[row][column],
3898                            1.0e-12,
3899                            &format!("tail case {case} fourth[{row}][{column}]"),
3900                        );
3901                    }
3902                }
3903            }
3904        }
3905
3906        /// The target-shaped M=32 storage schedules must remain an exact lowering
3907        /// of the canonical multinomial row program. This invokes the live
3908        /// `directional_fisher_jet_rows` and `second_directional_fisher_jet_rows`
3909        /// production entries, so x86-64-v3 exercises the contiguous first-order
3910        /// schedule while AVX-512-native builds exercise the symmetric static
3911        /// schedule. Mixed-second output is symmetric on both targets. The
3912        /// worker's 1 MiB stack is deliberately smaller than the 1,082,368-byte
3913        /// `TwoSeed<32>` primary array: passing proves the canonical evaluator
3914        /// selected its bounded heap storage rather than relying on test-runner
3915        /// stack configuration.
3916        #[test]
3917        fn multinomial_m32_production_directional_routes_match_canonical_jet_932() {
3918            const REGRESSION_STACK_BYTES: usize = 1024 * 1024;
3919            let worker = std::thread::Builder::new()
3920                .name("multinomial-m32-canonical-stack-bound".to_string())
3921                .stack_size(REGRESSION_STACK_BYTES)
3922                .spawn(|| {
3923                    const M: usize = 32;
3924                    assert_eq!(
3925                        M * std::mem::size_of::<gam_math::jet_scalar::TwoSeed<M>>(),
3926                        1_082_368,
3927                        "M=32 canonical fourth-order seed footprint changed"
3928                    );
3929                    let first_schedule = fisher_output_schedule::<OneSeed<0>>(M);
3930                    let expected_first = if AVX2_WITHOUT_AVX512 {
3931                        FisherOutputSchedule::ContiguousFull
3932                    } else {
3933                        FisherOutputSchedule::SymmetricTriangle
3934                    };
3935                    assert!(
3936                        first_schedule == expected_first,
3937                        "M=32 first-directional Fisher schedule does not match the target ISA"
3938                    );
3939                    assert!(
3940                        fisher_output_schedule::<TwoSeed<0>>(M)
3941                            == FisherOutputSchedule::SymmetricTriangle,
3942                        "M=32 second-directional Fisher schedule must retain symmetric output"
3943                    );
3944
3945                    for trial in 0..4 {
3946                        let eta: [f64; M] = std::array::from_fn(|axis| {
3947                            0.9 * ((axis * 7 + trial * 3 + 1) as f64 * 0.17).sin()
3948                                - 0.35 * ((axis + trial + 2) as f64 * 0.11).cos()
3949                        });
3950                        let direction: [f64; M] = std::array::from_fn(|axis| {
3951                            0.7 * ((axis * 5 + trial + 3) as f64 * 0.13).cos()
3952                                - 0.2 * ((axis + 2 * trial + 1) as f64 * 0.19).sin()
3953                        });
3954                        let direction_u: [f64; M] = std::array::from_fn(|axis| {
3955                            -0.6 * ((axis * 3 + trial + 4) as f64 * 0.09).sin()
3956                                + 0.25 * ((axis + trial + 5) as f64 * 0.23).cos()
3957                        });
3958                        let observed_class = if trial % 2 == 0 { trial } else { M };
3959                        let weight = 0.8 + 0.3 * trial as f64;
3960                        let family = single_row_family(observed_class, weight, M + 1);
3961                        let mut response = vec![0.0; M + 1];
3962                        response[observed_class] = 1.0;
3963                        let program = MultinomialLogitRowProgram::new(&eta, &response, weight)
3964                            .expect("valid M=32 multinomial row program");
3965
3966                        let production_first = prod_third(&family, &eta, &direction);
3967                        let canonical_first = program_third_contracted(&program, 0, &direction)
3968                            .expect("canonical M=32 first-directional Fisher contraction");
3969                        let production_second =
3970                            prod_fourth(&family, &eta, &direction_u, &direction);
3971                        let canonical_second =
3972                            program_fourth_contracted(&program, 0, &direction_u, &direction)
3973                                .expect("canonical M=32 second-directional Fisher contraction");
3974
3975                        for row in 0..M {
3976                            for column in 0..M {
3977                                close(
3978                                    production_first[row][column],
3979                                    canonical_first[row][column],
3980                                    JET_TOL,
3981                                    &format!(
3982                                        "M=32 trial {trial} first-directional[{row}][{column}]"
3983                                    ),
3984                                );
3985                                close(
3986                                    production_second[row][column],
3987                                    canonical_second[row][column],
3988                                    JET_TOL,
3989                                    &format!(
3990                                        "M=32 trial {trial} second-directional[{row}][{column}]"
3991                                    ),
3992                                );
3993                            }
3994                        }
3995                    }
3996                })
3997                .expect("spawn bounded-stack M=32 parity worker");
3998            if let Err(payload) = worker.join() {
3999                std::panic::resume_unwind(payload);
4000            }
4001        }
4002
4003        struct FirstFisherBuffers {
4004            normalized: Vec<f64>,
4005            derivative: Vec<f64>,
4006            fisher: Vec<f64>,
4007        }
4008
4009        impl FirstFisherBuffers {
4010            fn new(m: usize) -> Self {
4011                Self {
4012                    normalized: vec![0.0; m],
4013                    derivative: vec![0.0; m],
4014                    fisher: vec![0.0; m * m],
4015                }
4016            }
4017        }
4018
4019        struct SecondFisherBuffers {
4020            normalized: Vec<[f64; 3]>,
4021            derivative_u: Vec<f64>,
4022            derivative_v: Vec<f64>,
4023            mixed_derivative: Vec<f64>,
4024            fisher: Vec<f64>,
4025        }
4026
4027        impl SecondFisherBuffers {
4028            fn new(m: usize) -> Self {
4029                Self {
4030                    normalized: vec![[0.0; 3]; m],
4031                    derivative_u: vec![0.0; m],
4032                    derivative_v: vec![0.0; m],
4033                    mixed_derivative: vec![0.0; m],
4034                    fisher: vec![0.0; m * m],
4035                }
4036            }
4037        }
4038
4039        #[inline(never)]
4040        fn compiled_first_fisher<const M: usize>(
4041            probability: &[f64; M],
4042            direction: &[f64; M],
4043            weight: f64,
4044            buffers: &mut FirstFisherBuffers,
4045        ) {
4046            softmax_fisher_perturbation::<OneSeed<0>>(
4047                M,
4048                weight,
4049                |axis| probability[axis],
4050                |axis| direction[axis],
4051                |_| 0.0,
4052                &mut buffers.normalized,
4053                &mut buffers.fisher,
4054            );
4055        }
4056
4057        /// Direct, non-abstracted first directional derivative of
4058        /// `weight * (diag(p) - p p')`. The observation weight is folded into
4059        /// the probability derivative before matrix assembly, and the output
4060        /// loop uses the same ISA-optimal triangular/full-row choice available
4061        /// to a manually tuned implementation.
4062        #[inline(never)]
4063        fn strongest_hand_first_fisher<const M: usize>(
4064            probability: &[f64; M],
4065            direction: &[f64; M],
4066            weight: f64,
4067            buffers: &mut FirstFisherBuffers,
4068        ) {
4069            let mut mean = 0.0;
4070            for axis in 0..M {
4071                mean += probability[axis] * direction[axis];
4072            }
4073            for axis in 0..M {
4074                buffers.derivative[axis] = weight * probability[axis] * (direction[axis] - mean);
4075            }
4076            if fisher_output_schedule::<OneSeed<0>>(M) == FisherOutputSchedule::ContiguousFull {
4077                for row in 0..M {
4078                    let probability_row = probability[row];
4079                    let derivative_row = buffers.derivative[row];
4080                    for column in 0..M {
4081                        buffers.fisher[row * M + column] = -(derivative_row * probability[column]
4082                            + probability_row * buffers.derivative[column]);
4083                    }
4084                    buffers.fisher[row * M + row] += derivative_row;
4085                }
4086                return;
4087            }
4088            for row in 0..M {
4089                let probability_row = probability[row];
4090                let derivative_row = buffers.derivative[row];
4091                buffers.fisher[row * M + row] =
4092                    derivative_row - 2.0 * derivative_row * probability_row;
4093                for column in (row + 1)..M {
4094                    let coefficient = -(derivative_row * probability[column]
4095                        + probability_row * buffers.derivative[column]);
4096                    buffers.fisher[row * M + column] = coefficient;
4097                    buffers.fisher[column * M + row] = coefficient;
4098                }
4099            }
4100        }
4101
4102        #[inline(never)]
4103        fn compiled_second_fisher<const M: usize>(
4104            probability: &[f64; M],
4105            direction_u: &[f64; M],
4106            direction_v: &[f64; M],
4107            weight: f64,
4108            buffers: &mut SecondFisherBuffers,
4109        ) {
4110            softmax_fisher_perturbation::<TwoSeed<0>>(
4111                M,
4112                weight,
4113                |axis| probability[axis],
4114                |axis| direction_u[axis],
4115                |axis| direction_v[axis],
4116                &mut buffers.normalized,
4117                &mut buffers.fisher,
4118            );
4119        }
4120
4121        /// Direct, non-abstracted mixed second directional derivative of
4122        /// `weight * (diag(p) - p p')`. Every probability derivative is
4123        /// materialized exactly once, and symmetry halves the matrix work.
4124        #[inline(never)]
4125        fn strongest_hand_second_fisher<const M: usize>(
4126            probability: &[f64; M],
4127            direction_u: &[f64; M],
4128            direction_v: &[f64; M],
4129            weight: f64,
4130            buffers: &mut SecondFisherBuffers,
4131        ) {
4132            let mut mean_u = 0.0;
4133            let mut mean_v = 0.0;
4134            for axis in 0..M {
4135                mean_u += probability[axis] * direction_u[axis];
4136                mean_v += probability[axis] * direction_v[axis];
4137            }
4138            for axis in 0..M {
4139                buffers.derivative_u[axis] = probability[axis] * (direction_u[axis] - mean_u);
4140                buffers.derivative_v[axis] = probability[axis] * (direction_v[axis] - mean_v);
4141            }
4142            let mut mixed_mean = 0.0;
4143            for axis in 0..M {
4144                mixed_mean += buffers.derivative_v[axis] * direction_u[axis];
4145            }
4146            for axis in 0..M {
4147                buffers.mixed_derivative[axis] = buffers.derivative_v[axis]
4148                    * (direction_u[axis] - mean_u)
4149                    - probability[axis] * mixed_mean;
4150            }
4151            for row in 0..M {
4152                let probability_row = probability[row];
4153                let derivative_u_row = buffers.derivative_u[row];
4154                let derivative_v_row = buffers.derivative_v[row];
4155                let mixed_row = buffers.mixed_derivative[row];
4156                buffers.fisher[row * M + row] = weight
4157                    * (mixed_row
4158                        - 2.0 * mixed_row * probability_row
4159                        - 2.0 * derivative_u_row * derivative_v_row);
4160                for column in (row + 1)..M {
4161                    let coefficient = weight
4162                        * (-(mixed_row * probability[column]
4163                            + derivative_u_row * buffers.derivative_v[column]
4164                            + derivative_v_row * buffers.derivative_u[column]
4165                            + probability_row * buffers.mixed_derivative[column]));
4166                    buffers.fisher[row * M + column] = coefficient;
4167                    buffers.fisher[column * M + row] = coefficient;
4168                }
4169            }
4170        }
4171
4172        fn fisher_checksum(values: &[f64]) -> f64 {
4173            values
4174                .iter()
4175                .enumerate()
4176                .map(|(index, value)| value * (1 + index % 17) as f64)
4177                .sum()
4178        }
4179
4180        /// Binding #932 release gate for multinomial higher-order production.
4181        ///
4182        /// `softmax_fisher_perturbation` differentiates one canonical
4183        /// normalized-mass/Fisher expression and demand-prunes it to either the
4184        /// first or mixed-second directional coefficient. The opponents below
4185        /// are independent direct analytic schedules with no jet, scalar-field,
4186        /// or compiler abstraction. They cache every probability derivative
4187        /// once, exploit matrix symmetry where profitable, and select the same
4188        /// ISA-shaped full-row schedule as production for large first-order
4189        /// blocks.
4190        ///
4191        /// Both sides cross the same outlined ABI, receive the same 256 varied
4192        /// rows, reuse caller-owned scratch, and return the complete `M*M`
4193        /// matrix. Every matrix channel enters a feedback-coupled checksum;
4194        /// seven samples alternate contender order and the paired medians must
4195        /// be strict production wins at every representative width.
4196        /// Not `#[ignore]`d. The parity block below is build-independent and
4197        /// was dead coverage for as long as this test was ignored; it now runs
4198        /// in every build. The timing gate is reached only under `--release`,
4199        /// matching `release_measure_multinomial_specialized_vs_generic_tower_932`
4200        /// directly below, which is also a #932 release timing gate and is
4201        /// likewise not ignored.
4202        #[test]
4203        fn release_measure_multinomial_fisher_vs_strongest_hand_932() {
4204            use gam_math::paired_timing::paired_interleaved;
4205
4206            fn measure<const M: usize>(seed: u64, repetitions: usize) {
4207                const ROWS: usize = 256;
4208                let mut rng = Lcg(seed);
4209                let probability: Vec<[f64; M]> = (0..ROWS)
4210                    .map(|_| {
4211                        let raw: [f64; M] = std::array::from_fn(|_| rng.uniform(0.1, 1.0));
4212                        let scale = rng.uniform(0.35, 0.95) / raw.iter().sum::<f64>();
4213                        raw.map(|mass| mass * scale)
4214                    })
4215                    .collect();
4216                let direction_u: Vec<[f64; M]> = (0..ROWS)
4217                    .map(|_| std::array::from_fn(|_| rng.uniform(-0.8, 0.8)))
4218                    .collect();
4219                let direction_v: Vec<[f64; M]> = (0..ROWS)
4220                    .map(|_| std::array::from_fn(|_| rng.uniform(-0.8, 0.8)))
4221                    .collect();
4222                let weights: Vec<f64> = (0..ROWS).map(|_| rng.uniform(0.25, 2.5)).collect();
4223
4224                let mut compiled_first = FirstFisherBuffers::new(M);
4225                let mut hand_first = FirstFisherBuffers::new(M);
4226                let mut compiled_second = SecondFisherBuffers::new(M);
4227                let mut hand_second = SecondFisherBuffers::new(M);
4228
4229                for row in 0..ROWS {
4230                    compiled_first_fisher(
4231                        &probability[row],
4232                        &direction_u[row],
4233                        weights[row],
4234                        &mut compiled_first,
4235                    );
4236                    strongest_hand_first_fisher(
4237                        &probability[row],
4238                        &direction_u[row],
4239                        weights[row],
4240                        &mut hand_first,
4241                    );
4242                    compiled_second_fisher(
4243                        &probability[row],
4244                        &direction_u[row],
4245                        &direction_v[row],
4246                        weights[row],
4247                        &mut compiled_second,
4248                    );
4249                    strongest_hand_second_fisher(
4250                        &probability[row],
4251                        &direction_u[row],
4252                        &direction_v[row],
4253                        weights[row],
4254                        &mut hand_second,
4255                    );
4256                    for index in 0..M * M {
4257                        close(
4258                            compiled_first.fisher[index],
4259                            hand_first.fisher[index],
4260                            3.0e-15,
4261                            &format!("M={M} first strongest-hand parity[{row},{index}]"),
4262                        );
4263                        close(
4264                            compiled_second.fisher[index],
4265                            hand_second.fisher[index],
4266                            5.0e-15,
4267                            &format!("M={M} second strongest-hand parity[{row},{index}]"),
4268                        );
4269                    }
4270                }
4271
4272                // Everything above is a parity assertion and holds in any
4273                // build. The sweeps below cost roughly four million row
4274                // evaluations per width, and a hand-vs-compiled ratio measured
4275                // without optimization would gate on noise, so debug stops
4276                // here rather than asserting something it cannot observe.
4277                if cfg!(debug_assertions) {
4278                    return;
4279                }
4280
4281                let compiled_first_sweep = |nudge: f64, buffers: &mut FirstFisherBuffers| {
4282                    let mut checksum = nudge;
4283                    for row in 0..ROWS {
4284                        compiled_first_fisher(
4285                            &probability[row],
4286                            &direction_u[row],
4287                            weights[row] + checksum * 1.0e-18,
4288                            buffers,
4289                        );
4290                        checksum += fisher_checksum(&buffers.fisher);
4291                    }
4292                    checksum
4293                };
4294                let hand_first_sweep = |nudge: f64, buffers: &mut FirstFisherBuffers| {
4295                    let mut checksum = nudge;
4296                    for row in 0..ROWS {
4297                        strongest_hand_first_fisher(
4298                            &probability[row],
4299                            &direction_u[row],
4300                            weights[row] + checksum * 1.0e-18,
4301                            buffers,
4302                        );
4303                        checksum += fisher_checksum(&buffers.fisher);
4304                    }
4305                    checksum
4306                };
4307                let compiled_second_sweep = |nudge: f64, buffers: &mut SecondFisherBuffers| {
4308                    let mut checksum = nudge;
4309                    for row in 0..ROWS {
4310                        compiled_second_fisher(
4311                            &probability[row],
4312                            &direction_u[row],
4313                            &direction_v[row],
4314                            weights[row] + checksum * 1.0e-18,
4315                            buffers,
4316                        );
4317                        checksum += fisher_checksum(&buffers.fisher);
4318                    }
4319                    checksum
4320                };
4321                let hand_second_sweep = |nudge: f64, buffers: &mut SecondFisherBuffers| {
4322                    let mut checksum = nudge;
4323                    for row in 0..ROWS {
4324                        strongest_hand_second_fisher(
4325                            &probability[row],
4326                            &direction_u[row],
4327                            &direction_v[row],
4328                            weights[row] + checksum * 1.0e-18,
4329                            buffers,
4330                        );
4331                        checksum += fisher_checksum(&buffers.fisher);
4332                    }
4333                    checksum
4334                };
4335
4336                // One paired, interleaved, order-RANDOMISED measurement per
4337                // channel. This gate was already the best-built of the #932
4338                // population: it interleaved by round with `(round + side) % 2`
4339                // and took a MEDIAN, not a minimum. What it still did was divide
4340                // two PER-ARM medians -- so the pairing the interleave created
4341                // was discarded at the last step, and nothing reported whether a
4342                // verdict cleared the measurement's own resolution.
4343                let sweeps = (repetitions / 2).max(1);
4344                let first = paired_interleaved(
4345                    15,
4346                    sweeps,
4347                    seed ^ 0x1111_1111,
4348                    |nudge| compiled_first_sweep(nudge, &mut compiled_first),
4349                    |nudge| hand_first_sweep(nudge, &mut hand_first),
4350                );
4351                let second = paired_interleaved(
4352                    15,
4353                    sweeps,
4354                    seed ^ 0x2222_2222,
4355                    |nudge| compiled_second_sweep(nudge, &mut compiled_second),
4356                    |nudge| hand_second_sweep(nudge, &mut hand_second),
4357                );
4358                // `median_ratio` is hand / compiled, so above 1 means the
4359                // compiled lowering is faster -- the same orientation as the
4360                // `hand_over_compiled` token this gate has always printed. The
4361                // unit is ns per SWEEP over ROWS rows, not the historical
4362                // ns/row; the ratio the verdict rests on is unit-free either way.
4363                eprintln!(
4364                    "MULTINOMIAL-HAND-932 M={M} rows={ROWS} first {}",
4365                    first.summary("compiled", "strongest_hand"),
4366                );
4367                eprintln!(
4368                    "MULTINOMIAL-HAND-932 M={M} rows={ROWS} second {}",
4369                    second.summary("compiled", "strongest_hand"),
4370                );
4371                // CONTRACT UNCHANGED: the compiled lowering must beat the
4372                // strongest hand restatement, on both channels. `wins_fraction`
4373                // is what makes that a claim rather than a point estimate.
4374                assert!(
4375                    first.median_ratio() > 1.0 && first.wins_fraction() >= 0.75,
4376                    "M={M} first canonical lowering must beat strongest hand: {}",
4377                    first.summary("compiled", "strongest_hand"),
4378                );
4379                assert!(
4380                    second.median_ratio() > 1.0 && second.wins_fraction() >= 0.75,
4381                    "M={M} second canonical lowering must beat strongest hand: {}",
4382                    second.summary("compiled", "strongest_hand"),
4383                );
4384            }
4385
4386            measure::<2>(0x9322_0002_face_cafe, 2_000);
4387            measure::<3>(0x9323_0003_face_cafe, 2_000);
4388            measure::<8>(0x9328_0008_face_cafe, 600);
4389            measure::<32>(0x9332_0032_face_cafe, 80);
4390            measure::<64>(0x9364_0064_face_cafe, 24);
4391        }
4392
4393        /// #932 release speed gate for the multinomial-logit row. Production
4394        /// is the structure-compiled softmax lowering
4395        /// ([`MultinomialLogitRowProgram::value_gradient_hessian_into`], with
4396        /// const-hinted small-`M` shapes of its single body), timed against
4397        /// the generic gam-math forward-mode jet tower
4398        /// ([`program_row_kernel`]) — the naive automatic-differentiation
4399        /// baseline the retained specialization must beat, since #932 removed
4400        /// this family's `cfg(test)` hand restatement. Emits the diagnostic
4401        /// `generic_tower_over_production` (generic-tower time over production
4402        /// time) per active-class width. This validates the specialization
4403        /// against its generic oracle, but is deliberately not strongest-hand
4404        /// closure evidence.
4405        ///
4406        /// The batch of distinct rows supplies genuine per-row input variation, so
4407        /// the optimizer cannot hoist the pure row call out of the sweep, and the
4408        /// finite checksum over every returned channel keeps the whole sweep live
4409        /// without `std::hint::black_box`.
4410        #[test]
4411        fn release_measure_multinomial_specialized_vs_generic_tower_932() {
4412            fn measure<const M: usize>(seed: u64) {
4413                use std::time::Instant;
4414
4415                const ROWS: usize = 512;
4416                let mut rng = Lcg(seed);
4417                let mut etas: Vec<[f64; M]> = Vec::with_capacity(ROWS);
4418                let mut responses: Vec<Vec<f64>> = Vec::with_capacity(ROWS);
4419                let mut weights: Vec<f64> = Vec::with_capacity(ROWS);
4420                for row in 0..ROWS {
4421                    let eta: [f64; M] = std::array::from_fn(|_| rng.uniform(-2.5, 2.5));
4422                    let observed = row % (M + 1);
4423                    let mut response = vec![0.0; M + 1];
4424                    response[observed] = 1.0;
4425                    etas.push(eta);
4426                    responses.push(response);
4427                    weights.push(rng.uniform(0.25, 2.5));
4428                }
4429                let programs: Vec<MultinomialLogitRowProgram> = (0..ROWS)
4430                    .map(|row| {
4431                        MultinomialLogitRowProgram::new(&etas[row], &responses[row], weights[row])
4432                            .expect("valid multinomial batch row")
4433                    })
4434                    .collect();
4435
4436                let mut probabilities = vec![0.0_f64; M + 1];
4437                let mut gradient = vec![0.0_f64; M];
4438                let mut hessian = vec![0.0_f64; M * M];
4439
4440                // Warm both paths and pin that the production lowering and the
4441                // generic tower emit the same V/G/H, so the two timings measure
4442                // equal work.
4443                for program in &programs {
4444                    let (tower_value, tower_gradient, tower_hessian) =
4445                        program_row_kernel::<M, _>(program, 0).expect("tower warm kernel");
4446                    let production_value = program.value_gradient_hessian_into(
4447                        &mut probabilities,
4448                        &mut gradient,
4449                        &mut hessian,
4450                    );
4451                    close(
4452                        tower_value,
4453                        production_value,
4454                        JET_TOL,
4455                        &format!("M={M} release-measure value parity"),
4456                    );
4457                    for a in 0..M {
4458                        close(
4459                            tower_gradient[a],
4460                            gradient[a],
4461                            JET_TOL,
4462                            &format!("M={M} release-measure gradient[{a}] parity"),
4463                        );
4464                        for b in 0..M {
4465                            close(
4466                                tower_hessian[a][b],
4467                                hessian[a * M + b],
4468                                JET_TOL,
4469                                &format!("M={M} release-measure hessian[{a}][{b}] parity"),
4470                            );
4471                        }
4472                    }
4473                }
4474
4475                let best_secs = |sweep: &mut dyn FnMut() -> f64| -> f64 {
4476                    let mut best = f64::INFINITY;
4477                    for _ in 0..5 {
4478                        let started = Instant::now();
4479                        let checksum = sweep();
4480                        assert!(
4481                            checksum.is_finite(),
4482                            "multinomial release-measure checksum must stay finite"
4483                        );
4484                        best = best.min(started.elapsed().as_secs_f64());
4485                    }
4486                    best
4487                };
4488
4489                let mut production_sweep = || {
4490                    let mut checksum = 0.0_f64;
4491                    for program in &programs {
4492                        let value = program.value_gradient_hessian_into(
4493                            &mut probabilities,
4494                            &mut gradient,
4495                            &mut hessian,
4496                        );
4497                        checksum += value + gradient[0] + hessian[0];
4498                    }
4499                    checksum
4500                };
4501                let production_secs = best_secs(&mut production_sweep);
4502
4503                let mut tower_sweep = || {
4504                    let mut checksum = 0.0_f64;
4505                    for program in &programs {
4506                        let (value, tower_gradient, tower_hessian) =
4507                            program_row_kernel::<M, _>(program, 0).expect("tower kernel");
4508                        checksum += value + tower_gradient[0] + tower_hessian[0][0];
4509                    }
4510                    checksum
4511                };
4512                let tower_secs = best_secs(&mut tower_sweep);
4513
4514                let production_ns = production_secs * 1e9 / ROWS as f64;
4515                let tower_ns = tower_secs * 1e9 / ROWS as f64;
4516                eprintln!(
4517                    "MULTINOMIAL-RELEASE-932 M={M} rows={ROWS} production_ns={production_ns:.3} \
4518                     generic_tower_ns={tower_ns:.3} generic_tower_over_production={:.6}",
4519                    tower_ns / production_ns,
4520                );
4521            }
4522
4523            measure::<2>(0x9322_2020_0715_face);
4524            measure::<3>(0x0bad_c0de_0715_2020);
4525            measure::<4>(0x5eed_4444_0722_beef);
4526            measure::<8>(0x1234_5678_0715_abcd);
4527        }
4528    }
4529
4530    impl MultinomialFamily {
4531        /// Test-only convenience wrapper: assemble the batched first-directional
4532        /// derivatives directly from `eta`, computing the row probabilities
4533        /// internally. Production callers already hold the probabilities and use
4534        /// `assemble_directional_derivatives_from_probs`; the parity tests in this
4535        /// module drive the family from raw `eta`.
4536        fn assemble_directional_derivatives(
4537            &self,
4538            eta: ArrayView2<'_, f64>,
4539            directions: &[Array1<f64>],
4540        ) -> Result<Vec<Array2<f64>>, String> {
4541            let probs = self.row_probabilities(eta);
4542            self.assemble_directional_derivatives_from_probs(probs.view(), directions)
4543        }
4544
4545        /// Assemble `D_beta H[d_j]` for an arbitrary batch of coefficient
4546        /// directions in one shared softmax/probability pass.
4547        ///
4548        /// This is the outer-LAML mode-response counterpart to
4549        /// [`Self::assemble_all_axis_directional_derivatives`]: the directions are
4550        /// not canonical axes, but the row probabilities and design outer products
4551        /// are identical for every `d_j` at a frozen beta. Sharing that row sweep is
4552        /// the #1082 penguin lever; the old path rebuilt the softmax jet and dense
4553        /// Gram once per outer coordinate.
4554        ///
4555        /// #932 cutover: this dense block assembly is no longer on the production
4556        /// outer-Hessian path (the matrix-free `MultinomialDirectionalHyperOperator`
4557        /// replaced it). It lives here in the test module as the reference the
4558        /// ≤1e-10 parity oracle contracts the matrix-free operator against.
4559        fn assemble_directional_derivatives_from_probs(
4560            &self,
4561            probs_full: ArrayView2<'_, f64>,
4562            directions: &[Array1<f64>],
4563        ) -> Result<Vec<Array2<f64>>, String> {
4564            use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
4565
4566            let n_dirs = directions.len();
4567            if n_dirs == 0 {
4568                return Ok(Vec::new());
4569            }
4570            let n = self.weights.len();
4571            let p = self.design.ncols();
4572            let m = self.active_classes();
4573            let dim = m * p;
4574            for (idx, direction) in directions.iter().enumerate() {
4575                if direction.len() != dim {
4576                    return Err(format!(
4577                        "MultinomialFamily batched direction {idx} length {} != (K-1)·P = {dim}",
4578                        direction.len()
4579                    ));
4580                }
4581            }
4582            let design = self.design.view();
4583            // #1082: parallelise over the DIRECTION batch instead of rows, dropping
4584            // the `n_dirs·dim·dim` per-worker accumulator + `reduce` (see the note on
4585            // `assemble_all_axis_directional_derivatives`). Each direction owns one
4586            // `dim·dim` block and scans all rows independently; the per-row
4587            // arithmetic is unchanged (only the row-summation order differs, admitted
4588            // to 1e-10 by the batched-vs-per-direction parity test).
4589            let out: Vec<Array2<f64>> = directions
4590                .par_iter()
4591                .map(|direction| {
4592                    let mut mat = vec![0.0_f64; dim * dim];
4593                    let mut d_eta = vec![0.0_f64; m];
4594                    let mut dp = vec![0.0_f64; m];
4595                    for row in 0..n {
4596                        let w = self.weights[row];
4597                        if w == 0.0 {
4598                            continue;
4599                        }
4600                        let mut s = 0.0_f64;
4601                        for a in 0..m {
4602                            let base = a * p;
4603                            let mut eta_dir = 0.0_f64;
4604                            for i in 0..p {
4605                                eta_dir += design[[row, i]] * direction[base + i];
4606                            }
4607                            d_eta[a] = eta_dir;
4608                            s += probs_full[[row, a]] * eta_dir;
4609                        }
4610                        for a in 0..m {
4611                            dp[a] = probs_full[[row, a]] * (d_eta[a] - s);
4612                        }
4613
4614                        for a in 0..m {
4615                            let pa = probs_full[[row, a]];
4616                            let row_a = a * p;
4617                            let jaa = w * (dp[a] - 2.0 * dp[a] * pa);
4618                            if jaa != 0.0 {
4619                                for i in 0..p {
4620                                    let xi = design[[row, i]];
4621                                    if xi == 0.0 {
4622                                        continue;
4623                                    }
4624                                    let scaled = jaa * xi;
4625                                    let out_row = (row_a + i) * dim;
4626                                    for j in 0..p {
4627                                        mat[out_row + row_a + j] += scaled * design[[row, j]];
4628                                    }
4629                                }
4630                            }
4631                            for b in (a + 1)..m {
4632                                let pb = probs_full[[row, b]];
4633                                let jab = w * (-(dp[a] * pb + pa * dp[b]));
4634                                if jab == 0.0 {
4635                                    continue;
4636                                }
4637                                let row_b = b * p;
4638                                for i in 0..p {
4639                                    let xi = design[[row, i]];
4640                                    if xi == 0.0 {
4641                                        continue;
4642                                    }
4643                                    let scaled = jab * xi;
4644                                    let out_a = (row_a + i) * dim;
4645                                    let out_b = (row_b + i) * dim;
4646                                    for j in 0..p {
4647                                        let xj = design[[row, j]];
4648                                        let value = scaled * xj;
4649                                        mat[out_a + row_b + j] += value;
4650                                        mat[out_b + row_a + j] += value;
4651                                    }
4652                                }
4653                            }
4654                        }
4655                    }
4656                    let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
4657                        .expect("batched direction derivative buffer is dim·dim");
4658                    for i in 0..dim {
4659                        for j in (i + 1)..dim {
4660                            let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
4661                            mat[[i, j]] = avg;
4662                            mat[[j, i]] = avg;
4663                        }
4664                    }
4665                    mat
4666                })
4667                .collect();
4668            Ok(out)
4669        }
4670
4671        /// Assemble `D²_beta H[u_j, v_j]` for an arbitrary batch of coefficient
4672        /// direction pairs in one shared probability/design row sweep.
4673        ///
4674        /// The exact outer Hessian asks for one correction per ρ-pair, where both
4675        /// directions are mode responses rather than canonical axes. The old
4676        /// workspace default delegated each pair to
4677        /// [`Self::second_directional_fisher_jet`] plus `dense_block_xtwx`, rebuilding
4678        /// the same softmax probabilities and design Gram scatter for every pair.
4679        /// This fused path keeps the singular formula but amortizes the row walk
4680        /// across the whole `K(K+1)/2` pair batch (#1082).
4681        ///
4682        /// #932 cutover: test-module reference, the parity oracle's dense
4683        /// reference (see `assemble_directional_derivatives_from_probs`).
4684        fn assemble_second_directional_derivatives_from_probs(
4685            &self,
4686            probs_full: ArrayView2<'_, f64>,
4687            pairs: &[(Array1<f64>, Array1<f64>)],
4688        ) -> Result<Vec<Array2<f64>>, String> {
4689            use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
4690
4691            let n_pairs = pairs.len();
4692            if n_pairs == 0 {
4693                return Ok(Vec::new());
4694            }
4695            let n = self.weights.len();
4696            let p = self.design.ncols();
4697            let m = self.active_classes();
4698            let dim = m * p;
4699            for (idx, (u, v)) in pairs.iter().enumerate() {
4700                if u.len() != dim || v.len() != dim {
4701                    return Err(format!(
4702                        "MultinomialFamily batched second-directional pair {idx} lengths {} and {} != (K-1)·P = {dim}",
4703                        u.len(),
4704                        v.len()
4705                    ));
4706                }
4707            }
4708
4709            let design = self.design.view();
4710            // #1082: parallelise over the PAIR batch instead of rows, dropping the
4711            // `n_pairs·dim·dim` per-worker accumulator + `reduce` (this is the exact
4712            // outer Hessian's `K(K+1)/2` pair walk; see the note on
4713            // `assemble_all_axis_directional_derivatives`). Each pair owns one
4714            // `dim·dim` block and scans all rows independently; the per-row
4715            // arithmetic is unchanged (only the row-summation order differs, admitted
4716            // to 1e-10 by the workspace-batched-vs-per-pair parity test).
4717            let out: Vec<Array2<f64>> = pairs
4718                .par_iter()
4719                .map(|(u, v)| {
4720                    let mut mat = vec![0.0_f64; dim * dim];
4721                    let mut d_eta_u = vec![0.0_f64; m];
4722                    let mut d_eta_v = vec![0.0_f64; m];
4723                    let mut dp_u = vec![0.0_f64; m];
4724                    let mut dp_v = vec![0.0_f64; m];
4725                    let mut ddp = vec![0.0_f64; m];
4726                    for row in 0..n {
4727                        let w = self.weights[row];
4728                        if w == 0.0 {
4729                            continue;
4730                        }
4731                        let mut s_u = 0.0_f64;
4732                        let mut s_v = 0.0_f64;
4733                        for a in 0..m {
4734                            let base = a * p;
4735                            let mut eta_u = 0.0_f64;
4736                            let mut eta_v = 0.0_f64;
4737                            for i in 0..p {
4738                                let x = design[[row, i]];
4739                                eta_u += x * u[base + i];
4740                                eta_v += x * v[base + i];
4741                            }
4742                            d_eta_u[a] = eta_u;
4743                            d_eta_v[a] = eta_v;
4744                            s_u += probs_full[[row, a]] * eta_u;
4745                            s_v += probs_full[[row, a]] * eta_v;
4746                        }
4747
4748                        for a in 0..m {
4749                            let pa = probs_full[[row, a]];
4750                            dp_u[a] = pa * (d_eta_u[a] - s_u);
4751                            dp_v[a] = pa * (d_eta_v[a] - s_v);
4752                        }
4753
4754                        let mut ds_u_dv = 0.0_f64;
4755                        for a in 0..m {
4756                            ds_u_dv += dp_v[a] * d_eta_u[a];
4757                        }
4758                        for a in 0..m {
4759                            let pa = probs_full[[row, a]];
4760                            ddp[a] = dp_v[a] * (d_eta_u[a] - s_u) - pa * ds_u_dv;
4761                        }
4762
4763                        for a in 0..m {
4764                            let pa = probs_full[[row, a]];
4765                            let row_a = a * p;
4766                            let jaa = w * (ddp[a] - 2.0 * ddp[a] * pa - 2.0 * dp_u[a] * dp_v[a]);
4767                            if jaa != 0.0 {
4768                                for i in 0..p {
4769                                    let xi = design[[row, i]];
4770                                    if xi == 0.0 {
4771                                        continue;
4772                                    }
4773                                    let scaled = jaa * xi;
4774                                    let out_row = (row_a + i) * dim;
4775                                    for j in 0..p {
4776                                        mat[out_row + row_a + j] += scaled * design[[row, j]];
4777                                    }
4778                                }
4779                            }
4780
4781                            for b in (a + 1)..m {
4782                                let pb = probs_full[[row, b]];
4783                                let jab = -w
4784                                    * (ddp[a] * pb
4785                                        + dp_u[a] * dp_v[b]
4786                                        + dp_v[a] * dp_u[b]
4787                                        + pa * ddp[b]);
4788                                if jab == 0.0 {
4789                                    continue;
4790                                }
4791                                let row_b = b * p;
4792                                for i in 0..p {
4793                                    let xi = design[[row, i]];
4794                                    if xi == 0.0 {
4795                                        continue;
4796                                    }
4797                                    let scaled = jab * xi;
4798                                    let out_a = (row_a + i) * dim;
4799                                    let out_b = (row_b + i) * dim;
4800                                    for j in 0..p {
4801                                        let xj = design[[row, j]];
4802                                        let value = scaled * xj;
4803                                        mat[out_a + row_b + j] += value;
4804                                        mat[out_b + row_a + j] += value;
4805                                    }
4806                                }
4807                            }
4808                        }
4809                    }
4810                    let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
4811                        .expect("batched second-directional buffer is dim·dim");
4812                    for i in 0..dim {
4813                        for j in (i + 1)..dim {
4814                            let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
4815                            mat[[i, j]] = avg;
4816                            mat[[j, i]] = avg;
4817                        }
4818                    }
4819                    mat
4820                })
4821                .collect();
4822            Ok(out)
4823        }
4824    }
4825
4826    fn toy_family_with_penalties(
4827        n_obs: usize,
4828        p: usize,
4829        k: usize,
4830        n_penalties: usize,
4831    ) -> MultinomialFamily {
4832        let y = {
4833            let mut y = Array2::<f64>::zeros((n_obs, k));
4834            for i in 0..n_obs {
4835                y[[i, i % k]] = 1.0;
4836            }
4837            y
4838        };
4839        let weights = Array1::<f64>::ones(n_obs);
4840        let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
4841            ((i + j + 1) as f64).sin()
4842        }));
4843        let penalties = Arc::new(
4844            (0..n_penalties)
4845                .map(|t| {
4846                    crate::custom_family::PenaltyMatrix::Dense(Array2::<f64>::from_shape_fn(
4847                        (p, p),
4848                        |(i, j)| {
4849                            if i == j && i >= t.min(p.saturating_sub(1)) {
4850                                1.0
4851                            } else {
4852                                0.0
4853                            }
4854                        },
4855                    ))
4856                })
4857                .collect::<Vec<_>>(),
4858        );
4859        MultinomialFamily::new(y, weights, k, design, penalties)
4860            .expect("toy MultinomialFamily must construct")
4861    }
4862
4863    /// #2612: the outer search's coordinate count is the joint SPEC count, not
4864    /// `(K − 1) · n_penalties`.
4865    ///
4866    /// The equivariant carrier (#1587) emits one spec per class per penalty
4867    /// component when `K > 2`, and one shared centered spec per component when
4868    /// `K ≤ 2`. Any policy keyed on "how many ρ are there" that computes the
4869    /// pre-#1587 per-block product classifies every `K > 2` model as smaller
4870    /// than it is — by 50% at `K = 3`, which is where the four-smooth penguin
4871    /// fixture sits. This asserts the declared dimension against the specs the
4872    /// family actually emits, so the two cannot drift again.
4873    #[test]
4874    fn joint_smoothing_dimension_equals_the_specs_emitted_2612() {
4875        for (k, n_penalties) in [(3usize, 8usize), (3, 1), (2, 8), (4, 3)] {
4876            let family = toy_family_with_penalties(24, k, 5, n_penalties);
4877            let emitted = family
4878                .equivariant_class_penalty_specs()
4879                .expect("equivariant specs")
4880                .len();
4881            assert_eq!(
4882                family.joint_smoothing_dimension(),
4883                emitted,
4884                "K={k}, {n_penalties} penalty components: declared dimension must equal the \
4885                 number of joint specs the carrier emits"
4886            );
4887            let pre_1587_product = (k - 1) * n_penalties;
4888            if k > 2 {
4889                assert_ne!(
4890                    emitted, pre_1587_product,
4891                    "K={k} is exactly where the pre-#1587 product and the real coordinate \
4892                     count differ; if they agree here this test has stopped discriminating"
4893                );
4894            }
4895        }
4896    }
4897
4898    fn toy_family(n_obs: usize, p: usize, k: usize) -> MultinomialFamily {
4899        let y = {
4900            let mut y = Array2::<f64>::zeros((n_obs, k));
4901            for i in 0..n_obs {
4902                y[[i, i % k]] = 1.0;
4903            }
4904            y
4905        };
4906        let weights = Array1::<f64>::ones(n_obs);
4907        let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
4908            ((i + j + 1) as f64).sin()
4909        }));
4910        let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(
4911            Array2::<f64>::from_shape_fn((p, p), |(i, j)| if i == j { 1.0 } else { 0.0 }),
4912        )]);
4913        MultinomialFamily::new(y, weights, k, design, penalties)
4914            .expect("toy MultinomialFamily must construct")
4915    }
4916
4917    /// #2744: every class block must declare that it owns its geometry at the
4918    /// RAW coefficient width.
4919    ///
4920    /// The family assembles every joint quantity from the `X` it captured at
4921    /// construction, so its flat layout `(K−1)·P` is only meaningful while the
4922    /// block specs keep width `P`. If the canonicaliser column-reduces a block,
4923    /// the specs and the family denominate the same vector in two different
4924    /// widths and the family's own guard refuses the fit. The declaration is
4925    /// what stops that, so it is asserted directly rather than inferred from a
4926    /// fit that happens to have a full-rank design.
4927    #[test]
4928    fn class_blocks_lock_the_raw_coefficient_width_2744() {
4929        let family = toy_family(9, 4, 3);
4930        let specs = family.build_block_specs();
4931        assert_eq!(specs.len(), family.active_classes(), "one block per class");
4932        for spec in &specs {
4933            let callback = spec
4934                .jacobian_callback
4935                .as_ref()
4936                .unwrap_or_else(|| panic!("block '{}' must declare its output channel", spec.name));
4937            assert!(
4938                callback.locks_raw_width_reduction(),
4939                "block '{}' must lock the raw width: the family assembles from its own \
4940                 captured design, so a reduced block width desynchronises the flat layout",
4941                spec.name,
4942            );
4943            assert_eq!(
4944                spec.design.ncols(),
4945                family.design.ncols(),
4946                "block '{}' width must be the family's raw P",
4947                spec.name,
4948            );
4949        }
4950        // The guard the mismatch used to trip must accept the specs the family
4951        // itself builds — that is the layout being single-sourced.
4952        family
4953            .check_spec_coefficient_width(&specs, "raw-width self-check")
4954            .expect("the family's own specs must satisfy its flat-layout guard");
4955    }
4956
4957    #[test]
4958    fn convexity_certificate_tracks_the_complete_multinomial_objective() {
4959        let unbiased = toy_family(8, 3, 4).with_joint_jeffreys_term(false);
4960        assert!(unbiased.exact_newton_joint_hessian_beta_dependent());
4961        assert!(
4962            unbiased.inner_coefficient_objective_is_globally_convex(),
4963            "softmax Fisher curvature varies with beta but remains PSD"
4964        );
4965        assert_eq!(
4966            unbiased.pseudo_logdet_mode(),
4967            PseudoLogdetMode::PositiveDefinite,
4968            "reference coding removes the softmax gauge, so an accepted Laplace mode is SPD"
4969        );
4970
4971        let firth = unbiased.with_joint_jeffreys_term(true);
4972        assert!(
4973            !firth.inner_coefficient_objective_is_globally_convex(),
4974            "the conditioning-gated Jeffreys correction is outside the convexity proof"
4975        );
4976        let anchor = firth
4977            .coefficient_mode_homotopy_member(0.0)
4978            .expect("Jeffreys homotopy anchor")
4979            .expect("armed multinomial supplies a coefficient-mode homotopy");
4980        let midpoint = firth
4981            .coefficient_mode_homotopy_member(0.5)
4982            .expect("Jeffreys homotopy midpoint")
4983            .expect("armed multinomial supplies a coefficient-mode homotopy");
4984        let endpoint = firth
4985            .coefficient_mode_homotopy_member(1.0)
4986            .expect("Jeffreys homotopy endpoint")
4987            .expect("armed multinomial supplies a coefficient-mode homotopy");
4988        assert_eq!(anchor.joint_jeffreys_term_strength(), 0.0);
4989        assert!(
4990            anchor.inner_coefficient_objective_is_globally_convex(),
4991            "the homotopy anchor is exactly the unique unbiased softmax objective"
4992        );
4993        assert_eq!(midpoint.joint_jeffreys_term_strength(), 0.5);
4994        assert_eq!(endpoint.joint_jeffreys_term_strength(), 1.0);
4995    }
4996
4997    #[test]
4998    fn block_specs_have_one_per_active_class_in_order() {
4999        let family = toy_family(8, 3, 4);
5000        let specs = family.build_block_specs();
5001        assert_eq!(specs.len(), 3, "expected K-1 = 3 active blocks for K=4");
5002        for (a, spec) in specs.iter().enumerate() {
5003            assert_eq!(spec.name, format!("class_{a}"));
5004        }
5005    }
5006
5007    #[test]
5008    fn gauge_priority_is_strictly_decreasing_in_class_index() {
5009        let family = toy_family(8, 3, 5);
5010        let specs = family.build_block_specs();
5011        for window in specs.windows(2) {
5012            assert!(
5013                window[0].gauge_priority > window[1].gauge_priority,
5014                "class_{} priority {} must exceed class_{} priority {}",
5015                window[0].name,
5016                window[0].gauge_priority,
5017                window[1].name,
5018                window[1].gauge_priority,
5019            );
5020        }
5021    }
5022
5023    /// #2744, the other end of the same contract: the CANONICALISER must honour
5024    /// the raw-width declaration on a shared design that is genuinely
5025    /// rank-deficient.
5026    ///
5027    /// The failing arm's design is `s(x1) + s(x2) + te(x1, x2)`, where the
5028    /// tensor term re-spans its own marginals — one column lies in the span of
5029    /// two others. That shape is reproduced here directly, so the audit has a
5030    /// real deficiency to attribute and the assertion is not vacuous: without
5031    /// the lock the `#933` path reduces both class blocks and the family's flat
5032    /// layout no longer describes the specs the solver holds.
5033    #[test]
5034    fn canonicalisation_keeps_multinomial_blocks_at_raw_width_2744() {
5035        let (n, p, k) = (48, 4, 3);
5036        let mut family = toy_family(n, p, k);
5037        // Column `p-1` becomes an exact linear combination of columns 0 and 1 —
5038        // the marginal/tensor confounding, not a duplicated-column alias pair.
5039        let deficient = {
5040            let mut design = (*family.design).clone();
5041            let combo = &design.column(0).to_owned() * 0.75 + &design.column(1).to_owned() * 0.5;
5042            design.column_mut(p - 1).assign(&combo);
5043            design
5044        };
5045        family.design = Arc::new(deficient);
5046        let specs = family.build_block_specs();
5047
5048        let canonical = gam_identifiability::canonical::canonicalize_for_identifiability(
5049            &specs,
5050            &vec![gam_problem::CoefficientCoordinate::Spanning; specs.len()],
5051        )
5052            .expect("a rank-deficient shared design must canonicalise, not fail closed");
5053
5054        // NON-VACUITY CONTROL: the audit must actually have found the
5055        // deficiency. If it attributed nothing there would be no reduction to
5056        // suppress and the width assertion below would pass on any code.
5057        assert!(
5058            !canonical.audit.dropped_columns.is_empty(),
5059            "the fixture must present the audit with a real rank deficiency to attribute; \
5060             it reported none, so the raw-width assertion would be vacuous"
5061        );
5062        for (raw, reduced) in specs.iter().zip(canonical.reduced_specs.iter()) {
5063            assert_eq!(
5064                reduced.design.ncols(),
5065                raw.design.ncols(),
5066                "block '{}' was column-reduced despite locking its raw width",
5067                raw.name,
5068            );
5069        }
5070        family
5071            .check_spec_coefficient_width(&canonical.reduced_specs, "canonicalised specs")
5072            .expect("the canonicalised specs must still match the family's flat layout");
5073    }
5074
5075    #[test]
5076    fn block_specs_share_design_shape_with_family() {
5077        let family = toy_family(8, 3, 4);
5078        let specs = family.build_block_specs();
5079        let (n, p) = (family.design.nrows(), family.design.ncols());
5080        for spec in &specs {
5081            assert_eq!(spec.design.nrows(), n);
5082            assert_eq!(spec.design.ncols(), p);
5083        }
5084    }
5085
5086    #[test]
5087    fn per_term_smoothing_is_carried_by_equivariant_class_penalties() {
5088        let single = toy_family(6, 4, 3);
5089        for spec in &single.build_block_specs() {
5090            assert!(
5091                spec.penalties.is_empty()
5092                    && spec.initial_log_lambdas.is_empty()
5093                    && spec.nullspace_dims.is_empty(),
5094                "per-class blocks must attach no smooth penalty — the ALR-anchored \
5095                 per-block carrier is reference-dependent (#1587); the equivariant \
5096                 per-class centered joint family is the sole carrier"
5097            );
5098        }
5099        let joint = single.joint_penalty_specs().expect("joint specs");
5100        assert_eq!(
5101            joint.len(),
5102            3, // K = 3 per-class specs for the single term
5103            "one per-class centered penalty per (term, class), reference included"
5104        );
5105
5106        let p = 5;
5107        let k = 4;
5108        let n_terms = 3;
5109        let n_obs = 9;
5110        let y = {
5111            let mut y = Array2::<f64>::zeros((n_obs, k));
5112            for i in 0..n_obs {
5113                y[[i, i % k]] = 1.0;
5114            }
5115            y
5116        };
5117        let weights = Array1::<f64>::ones(n_obs);
5118        let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
5119            ((i + j + 1) as f64).cos()
5120        }));
5121        let penalties = Arc::new(
5122            (0..n_terms)
5123                .map(|t| {
5124                    crate::custom_family::PenaltyMatrix::Dense(Array2::<f64>::from_shape_fn(
5125                        (p, p),
5126                        |(i, j)| if i == j { (t + 1) as f64 } else { 0.0 },
5127                    ))
5128                })
5129                .collect::<Vec<_>>(),
5130        );
5131        let multi = MultinomialFamily::new(y, weights, k, design, penalties)
5132            .expect("multi-term MultinomialFamily must construct");
5133        let specs = multi.build_block_specs();
5134        assert_eq!(specs.len(), k - 1, "one block per active class");
5135        for spec in &specs {
5136            assert!(spec.penalties.is_empty());
5137            assert!(spec.initial_log_lambdas.is_empty());
5138            assert!(spec.nullspace_dims.is_empty());
5139        }
5140        let joint = multi.joint_penalty_specs().expect("joint specs");
5141        assert_eq!(
5142            joint.len(),
5143            n_terms * k,
5144            "K per-class centered penalties per term, term-major"
5145        );
5146        let m = k - 1;
5147        let raw_total = m * p;
5148        for (t_idx, term_specs) in joint.chunks(k).enumerate() {
5149            // Equal λ across the K per-class specs must reproduce the shared
5150            // centered metric penalty M ⊗ S_t exactly: Σ_c C_cᵀC_c = I − J/K.
5151            let mut sum = Array2::<f64>::zeros((raw_total, raw_total));
5152            for (c, spec) in term_specs.iter().enumerate() {
5153                assert_eq!(
5154                    spec.label.as_deref(),
5155                    Some(format!("multinomial_term_{t_idx}_class_{c}").as_str())
5156                );
5157                // rank(C_cᵀC_c ⊗ S_t) = rank(S_t) = p (diagonal PD fixtures).
5158                assert_eq!(spec.nullspace_dim, raw_total - p);
5159                sum += &spec.matrix;
5160            }
5161            let centered = multi
5162                .centered_joint_penalty_specs()
5163                .expect("centered specs");
5164            let target = &centered[t_idx].matrix;
5165            let max_err = sum
5166                .iter()
5167                .zip(target.iter())
5168                .map(|(a, b)| (a - b).abs())
5169                .fold(0.0_f64, f64::max);
5170            assert!(
5171                max_err < 1e-14,
5172                "Σ_c C_cᵀC_c ⊗ S_t must equal M ⊗ S_t (max err {max_err:.2e})"
5173            );
5174        }
5175    }
5176
5177    #[test]
5178    fn block_specs_keep_independent_lambda_per_class_and_term() {
5179        let p = 5;
5180        let k = 4;
5181        let n_terms = 3;
5182        let n_obs = 9;
5183        let y = {
5184            let mut y = Array2::<f64>::zeros((n_obs, k));
5185            for i in 0..n_obs {
5186                y[[i, i % k]] = 1.0;
5187            }
5188            y
5189        };
5190        let weights = Array1::<f64>::ones(n_obs);
5191        let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
5192            ((i + j + 1) as f64).cos()
5193        }));
5194        let penalties = Arc::new(
5195            (0..n_terms)
5196                .map(|t| {
5197                    crate::custom_family::PenaltyMatrix::Dense(Array2::<f64>::from_shape_fn(
5198                        (p, p),
5199                        |(i, j)| if i == j { (t + 1) as f64 } else { 0.0 },
5200                    ))
5201                })
5202                .collect::<Vec<_>>(),
5203        );
5204        let multi = MultinomialFamily::new(y, weights, k, design, penalties)
5205            .expect("multi-term MultinomialFamily must construct");
5206        let specs = multi.build_block_specs();
5207        assert_eq!(specs.len(), k - 1);
5208        // Independent per-class smoothness survives as one λ_{t,c} per (term,
5209        // class) on the CENTERED class functions — a gauge-free coordinate per
5210        // class — never as per-block ALR penalties (reference-anchored, #1587).
5211        let joint = multi.joint_penalty_specs().expect("joint specs");
5212        assert_eq!(joint.len(), n_terms * k);
5213        let labels: Vec<&str> = joint.iter().filter_map(|s| s.label.as_deref()).collect();
5214        assert_eq!(
5215            labels.len(),
5216            n_terms * k,
5217            "every spec carries its own label"
5218        );
5219        let unique: std::collections::HashSet<&str> = labels.iter().copied().collect();
5220        assert_eq!(
5221            unique.len(),
5222            labels.len(),
5223            "distinct labels ⇒ one independent outer λ per (term, class)"
5224        );
5225        for spec in &specs {
5226            assert!(spec.penalties.is_empty());
5227        }
5228    }
5229
5230    #[test]
5231    fn collect_eta_matrix_rejects_wrong_block_count() {
5232        let family = toy_family(4, 2, 3);
5233        let single = vec![ParameterBlockState {
5234            beta: Array1::<f64>::zeros(2),
5235            eta: Array1::<f64>::zeros(4),
5236        }];
5237        assert!(family.collect_eta_matrix(&single).is_err());
5238    }
5239
5240    #[test]
5241    fn evaluate_uniform_eta_zero_matches_uniform_softmax() {
5242        let family = toy_family(5, 2, 3);
5243        let p = family.design.ncols();
5244        let m = family.active_classes();
5245        let n = family.weights.len();
5246        let block_states: Vec<ParameterBlockState> = (0..m)
5247            .map(|_| ParameterBlockState {
5248                beta: Array1::<f64>::zeros(p),
5249                eta: Array1::<f64>::zeros(n),
5250            })
5251            .collect();
5252        let eval = family
5253            .evaluate(&block_states)
5254            .expect("baseline evaluate must succeed at β = 0");
5255        let expected = (n as f64) * (1.0 / (family.total_classes as f64)).ln();
5256        let diff = (eval.log_likelihood - expected).abs();
5257        assert!(
5258            diff < 1.0e-10,
5259            "baseline log-lik {} != {}",
5260            eval.log_likelihood,
5261            expected,
5262        );
5263        assert_eq!(eval.blockworking_sets.len(), m);
5264    }
5265
5266    #[test]
5267    fn directional_fisher_jet_along_zero_vanishes() {
5268        let family = toy_family(4, 2, 3);
5269        let p = family.design.ncols();
5270        let m = family.active_classes();
5271        let n = family.weights.len();
5272        let eta = Array2::<f64>::zeros((n, m));
5273        let d_beta = Array1::<f64>::zeros(m * p);
5274        let jet = family
5275            .directional_fisher_jet(eta.view(), &d_beta)
5276            .expect("zero direction must be valid");
5277        for &v in jet.iter() {
5278            assert!(v.abs() < 1.0e-14, "expected zero kernel, got {v}");
5279        }
5280    }
5281
5282    #[test]
5283    fn beta_flat_dim_equals_active_classes_times_p() {
5284        let family = toy_family(3, 5, 4);
5285        assert_eq!(family.beta_flat_dim(), 3 * 5);
5286    }
5287
5288    #[test]
5289    fn matrix_free_matvec_matches_dense_hessian_dot() {
5290        // Issue #347: the matrix-free H·v contraction must equal the dense
5291        // Hessian times v to floating tolerance, at a non-trivial β so the
5292        // softmax is away from the uniform point.
5293        let family = toy_family(7, 3, 4);
5294        let p = family.design.ncols();
5295        let m = family.active_classes();
5296        let n = family.weights.len();
5297        let design = family.design.view();
5298        // Distinct per-class β so η, and hence the Fisher block, is non-uniform.
5299        let block_states: Vec<ParameterBlockState> = (0..m)
5300            .map(|a| {
5301                let beta =
5302                    Array1::<f64>::from_shape_fn(p, |i| 0.3 * ((a + 1) as f64) - 0.1 * (i as f64));
5303                let eta = Array1::<f64>::from_shape_fn(n, |row| {
5304                    (0..p).map(|i| design[[row, i]] * beta[i]).sum()
5305                });
5306                ParameterBlockState { beta, eta }
5307            })
5308            .collect();
5309        let specs = family.build_block_specs();
5310        let ws = family
5311            .exact_newton_joint_hessian_workspace(&block_states, &specs)
5312            .expect("workspace build must succeed")
5313            .expect("workspace must be present");
5314        let dense = family
5315            .exact_newton_joint_hessian(&block_states)
5316            .expect("dense Hessian must build")
5317            .expect("dense Hessian must be present");
5318        // Several probe directions, including a unit vector per coordinate.
5319        for seed in 0..(m * p) {
5320            let v = Array1::<f64>::from_shape_fn(m * p, |i| {
5321                if i == seed {
5322                    1.0
5323                } else {
5324                    0.07 * ((i + 1) as f64).cos()
5325                }
5326            });
5327            let mf = ws
5328                .hessian_matvec(&v)
5329                .expect("matvec must succeed")
5330                .expect("matvec must be present");
5331            let dv = dense.dot(&v);
5332            for (a, b) in mf.iter().zip(dv.iter()) {
5333                assert!(
5334                    (a - b).abs() < 1.0e-9,
5335                    "matrix-free matvec {a} != dense {b}"
5336                );
5337            }
5338            // hessian_matvec_into must agree with the owned form.
5339            let mut into = Array1::<f64>::from_elem(m * p, f64::NAN);
5340            let wrote = ws
5341                .hessian_matvec_into(&v, &mut into)
5342                .expect("matvec_into must succeed");
5343            assert!(wrote, "matvec_into must report it wrote");
5344            for (a, b) in into.iter().zip(mf.iter()) {
5345                assert!((a - b).abs() < 1.0e-12, "matvec_into {a} != matvec {b}");
5346            }
5347        }
5348        // Diagonal must equal the dense diagonal.
5349        let mf_diag = ws
5350            .hessian_diagonal()
5351            .expect("diagonal must succeed")
5352            .expect("diagonal must be present");
5353        let dense_diag = dense.diag();
5354        for (a, b) in mf_diag.iter().zip(dense_diag.iter()) {
5355            assert!((a - b).abs() < 1.0e-9, "matrix-free diag {a} != dense {b}");
5356        }
5357    }
5358
5359    #[test]
5360    fn batched_second_directional_all_axes_matches_per_axis() {
5361        // The #1082 fix: `assemble_all_axis_second_directional_derivatives`
5362        // (one Gram-assembly pass for all p axes) must equal the per-axis route
5363        // `exact_newton_joint_hessiansecond_directional_derivative(e_a)` the
5364        // generic trait default loops, axis-by-axis, to bit-tight tolerance.
5365        let family = toy_family(9, 3, 4);
5366        let p = family.design.ncols();
5367        let m = family.active_classes();
5368        let n = family.weights.len();
5369        let design = family.design.view();
5370        let block_states: Vec<ParameterBlockState> = (0..m)
5371            .map(|a| {
5372                let beta = Array1::<f64>::from_shape_fn(p, |i| {
5373                    0.25 * ((a + 1) as f64) - 0.13 * (i as f64)
5374                });
5375                let eta = Array1::<f64>::from_shape_fn(n, |row| {
5376                    (0..p).map(|i| design[[row, i]] * beta[i]).sum()
5377                });
5378                ParameterBlockState { beta, eta }
5379            })
5380            .collect();
5381        let specs = family.build_block_specs();
5382        let dim = m * p;
5383
5384        // A non-trivial first direction δ (not a canonical axis).
5385        let delta = Array1::<f64>::from_shape_fn(dim, |i| {
5386            0.4 - 0.07 * (i as f64) + 0.03 * ((i * i) as f64).cos()
5387        });
5388
5389        // Batched: all axes in one pass.
5390        let batched = family
5391            .joint_jeffreys_information_second_directional_all_axes_with_specs(
5392                &block_states,
5393                &specs,
5394                &delta,
5395            )
5396            .expect("batched second-directional must succeed")
5397            .expect("batched second-directional must be present");
5398        assert_eq!(batched.len(), dim, "one matrix per canonical axis");
5399
5400        // Per-axis reference: the route the generic trait default takes.
5401        for axis in 0..dim {
5402            let mut e_a = Array1::<f64>::zeros(dim);
5403            e_a[axis] = 1.0;
5404            let per_axis = family
5405                .exact_newton_joint_hessiansecond_directional_derivative(
5406                    &block_states,
5407                    &delta,
5408                    &e_a,
5409                )
5410                .expect("per-axis second-directional must succeed")
5411                .expect("per-axis second-directional must be present");
5412            assert_eq!(batched[axis].dim(), (dim, dim));
5413            for r in 0..dim {
5414                for c in 0..dim {
5415                    let a = batched[axis][[r, c]];
5416                    let b = per_axis[[r, c]];
5417                    assert!(
5418                        (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
5419                        "axis {axis} entry ({r},{c}): batched {a} != per-axis {b}"
5420                    );
5421                }
5422            }
5423        }
5424    }
5425
5426    #[test]
5427    fn batched_general_directional_derivatives_match_per_direction() {
5428        // The penguin #1082 timeout spends each exact outer-gradient eval
5429        // rebuilding `D_beta H[delta_j]` for many non-canonical mode-response
5430        // directions. The workspace batch must preserve the old per-direction
5431        // arithmetic while sharing the row/probability sweep.
5432        let family = toy_family(11, 4, 3);
5433        let p = family.design.ncols();
5434        let m = family.active_classes();
5435        let n = family.weights.len();
5436        let dim = m * p;
5437        let design = family.design.view();
5438        let block_states: Vec<ParameterBlockState> = (0..m)
5439            .map(|a| {
5440                let beta = Array1::<f64>::from_shape_fn(p, |i| {
5441                    0.18 * ((a + 2) as f64) + 0.09 * ((i + 1) as f64).sin()
5442                });
5443                let eta = Array1::<f64>::from_shape_fn(n, |row| {
5444                    (0..p).map(|i| design[[row, i]] * beta[i]).sum()
5445                });
5446                ParameterBlockState { beta, eta }
5447            })
5448            .collect();
5449        let eta = family
5450            .collect_eta_matrix(&block_states)
5451            .expect("eta collection must succeed");
5452        let directions: Vec<Array1<f64>> = (0..5)
5453            .map(|seed| {
5454                Array1::<f64>::from_shape_fn(dim, |idx| {
5455                    0.31 * ((seed + 1 + idx) as f64).sin()
5456                        - 0.07 * ((seed * 3 + idx + 2) as f64).cos()
5457                })
5458            })
5459            .collect();
5460
5461        let batched = family
5462            .assemble_directional_derivatives(eta.view(), &directions)
5463            .expect("batched first directional derivatives must succeed");
5464        assert_eq!(batched.len(), directions.len());
5465        for (dir_idx, direction) in directions.iter().enumerate() {
5466            let per_direction = family
5467                .exact_newton_joint_hessian_directional_derivative(&block_states, direction)
5468                .expect("per-direction derivative must succeed")
5469                .expect("per-direction derivative must be present");
5470            for r in 0..dim {
5471                for c in 0..dim {
5472                    let a = batched[dir_idx][[r, c]];
5473                    let b = per_direction[[r, c]];
5474                    assert!(
5475                        (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
5476                        "direction {dir_idx} entry ({r},{c}): batched {a} != per-direction {b}"
5477                    );
5478                }
5479            }
5480        }
5481
5482        let specs = family.build_block_specs();
5483        let workspace = family
5484            .exact_newton_joint_hessian_workspace(&block_states, &specs)
5485            .expect("workspace build must succeed")
5486            .expect("workspace must be present");
5487        let operators = workspace
5488            .directional_derivative_operators(&directions)
5489            .expect("workspace batched operators must succeed");
5490        assert_eq!(operators.len(), directions.len());
5491        for (dir_idx, maybe_operator) in operators.into_iter().enumerate() {
5492            let dense = maybe_operator
5493                .expect("workspace must return a derivative operator")
5494                .to_dense();
5495            for r in 0..dim {
5496                for c in 0..dim {
5497                    let a = dense[[r, c]];
5498                    let b = batched[dir_idx][[r, c]];
5499                    assert!(
5500                        (a - b).abs() <= 1e-12 * (1.0 + b.abs()),
5501                        "operator direction {dir_idx} entry ({r},{c}): {a} != {b}"
5502                    );
5503                }
5504            }
5505        }
5506    }
5507
5508    #[test]
5509    fn workspace_batched_second_directional_pairs_match_per_pair() {
5510        // The exact outer Hessian sends arbitrary mode-response pairs through
5511        // `second_directional_derivative_operators`. This is the #1082 penguin
5512        // hot path: all pair corrections must be fused without changing the
5513        // old per-pair second-directional operator values.
5514        let family = toy_family(10, 4, 4);
5515        let p = family.design.ncols();
5516        let m = family.active_classes();
5517        let n = family.weights.len();
5518        let dim = m * p;
5519        let design = family.design.view();
5520        let block_states: Vec<ParameterBlockState> = (0..m)
5521            .map(|a| {
5522                let beta = Array1::<f64>::from_shape_fn(p, |i| {
5523                    0.11 * ((a + 3) as f64) - 0.06 * ((i + 2) as f64).cos()
5524                });
5525                let eta = Array1::<f64>::from_shape_fn(n, |row| {
5526                    (0..p).map(|i| design[[row, i]] * beta[i]).sum()
5527                });
5528                ParameterBlockState { beta, eta }
5529            })
5530            .collect();
5531        let specs = family.build_block_specs();
5532        let workspace = family
5533            .exact_newton_joint_hessian_workspace(&block_states, &specs)
5534            .expect("workspace build must succeed")
5535            .expect("workspace must be present");
5536        let pairs: Vec<(Array1<f64>, Array1<f64>)> = (0..7)
5537            .map(|seed| {
5538                let u = Array1::<f64>::from_shape_fn(dim, |idx| {
5539                    0.19 * ((seed + idx + 1) as f64).sin()
5540                        + 0.05 * ((2 * seed + idx + 3) as f64).cos()
5541                });
5542                let v = Array1::<f64>::from_shape_fn(dim, |idx| {
5543                    -0.17 * ((seed + 2 * idx + 5) as f64).cos()
5544                        + 0.04 * ((seed + idx + 7) as f64).sin()
5545                });
5546                (u, v)
5547            })
5548            .collect();
5549
5550        let batched = workspace
5551            .second_directional_derivative_operators(&pairs)
5552            .expect("workspace batched second-directional operators must succeed");
5553        assert_eq!(batched.len(), pairs.len());
5554
5555        for (pair_idx, ((u, v), maybe_operator)) in
5556            pairs.iter().zip(batched.into_iter()).enumerate()
5557        {
5558            let dense = maybe_operator
5559                .expect("workspace must return a second-directional operator")
5560                .to_dense();
5561            let per_pair = family
5562                .exact_newton_joint_hessiansecond_directional_derivative(&block_states, u, v)
5563                .expect("per-pair second-directional must succeed")
5564                .expect("per-pair second-directional must be present");
5565            for r in 0..dim {
5566                for c in 0..dim {
5567                    let a = dense[[r, c]];
5568                    let b = per_pair[[r, c]];
5569                    assert!(
5570                        (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
5571                        "pair {pair_idx} entry ({r},{c}): batched {a} != per-pair {b}"
5572                    );
5573                }
5574            }
5575        }
5576    }
5577
5578    /// Issue #932 ORACLE: the matrix-free directional / second-directional
5579    /// joint-Hessian operator must reproduce the dense
5580    /// `DenseMatrixHyperOperator` path to ≤1e-10 on every consumed surface —
5581    /// the full projected matrix `Fᵀ B F`, its trace, the matvec `B·v`, and the
5582    /// dense materialization `B`. This pins the #932 cutover's strict
5583    /// outer-Hessian parity contract: the matrix-free operator is now the sole
5584    /// production path, so this oracle (and the existing batched-operator tests
5585    /// that exercise `to_dense`) are the regression guard against any drift.
5586    #[test]
5587    fn matrix_free_directional_operator_matches_dense_oracle() {
5588        // A few representative small fits (the operator path fires for small
5589        // `total_rho_dim`): vary N, P, K and the projection rank.  The final
5590        // two cases exercise the K=3 full-rank and one-gauge-removed factors
5591        // used by the penguins outer solve, not only skinny test projections.
5592        for &(n, p, k, rank) in &[
5593            (11, 4, 3, 2),
5594            (9, 5, 4, 3),
5595            (13, 3, 5, 4),
5596            (7, 6, 3, 1),
5597            (17, 10, 3, 20),
5598            (17, 10, 3, 19),
5599        ] {
5600            let family = toy_family(n, p, k);
5601            let m = family.active_classes();
5602            let dim = m * p;
5603            let design = family.design.view();
5604            let block_states: Vec<ParameterBlockState> = (0..m)
5605                .map(|a| {
5606                    let beta = Array1::<f64>::from_shape_fn(p, |i| {
5607                        0.13 * ((a + 2) as f64) - 0.08 * ((i + 1) as f64).cos()
5608                    });
5609                    let eta = Array1::<f64>::from_shape_fn(n, |row| {
5610                        (0..p).map(|i| design[[row, i]] * beta[i]).sum()
5611                    });
5612                    ParameterBlockState { beta, eta }
5613                })
5614                .collect();
5615            let eta = family
5616                .collect_eta_matrix(&block_states)
5617                .expect("eta collection must succeed");
5618            let probs = family.row_probabilities(eta.view());
5619
5620            // Representative dense factor F (dim × rank) and a probe vector.
5621            let factor = Array2::<f64>::from_shape_fn((dim, rank), |(r, c)| {
5622                0.41 * ((r + 2 * c + 1) as f64).sin() - 0.12 * ((3 * r + c + 2) as f64).cos()
5623            });
5624            let probe = Array1::<f64>::from_shape_fn(dim, |idx| {
5625                0.27 * ((idx + 1) as f64).sin() + 0.05 * ((idx + 3) as f64).cos()
5626            });
5627
5628            let directions: Vec<Array1<f64>> = (0..4)
5629                .map(|seed| {
5630                    Array1::<f64>::from_shape_fn(dim, |idx| {
5631                        0.29 * ((seed + idx + 1) as f64).sin()
5632                            - 0.06 * ((2 * seed + idx + 2) as f64).cos()
5633                    })
5634                })
5635                .collect();
5636
5637            // First-directional: dense vs matrix-free.
5638            let dense_mats = family
5639                .assemble_directional_derivatives_from_probs(probs.view(), &directions)
5640                .expect("dense directional assembly must succeed");
5641            for (idx, direction) in directions.iter().enumerate() {
5642                let dense = DenseMatrixHyperOperator {
5643                    matrix: dense_mats[idx].clone(),
5644                };
5645                let mf = family
5646                    .directional_hyper_operator(
5647                        probs.view(),
5648                        direction,
5649                        Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
5650                    )
5651                    .expect("matrix-free directional operator must build");
5652                assert_oracle_parity(
5653                    &dense,
5654                    &mf,
5655                    &factor,
5656                    &probe,
5657                    &format!("dir {idx} n={n} p={p} k={k}"),
5658                );
5659            }
5660
5661            // Second-directional: dense vs matrix-free.
5662            let pairs: Vec<(Array1<f64>, Array1<f64>)> = (0..3)
5663                .map(|seed| {
5664                    let u = Array1::<f64>::from_shape_fn(dim, |idx| {
5665                        0.21 * ((seed + idx + 1) as f64).sin()
5666                    });
5667                    let v = Array1::<f64>::from_shape_fn(dim, |idx| {
5668                        -0.18 * ((seed + 2 * idx + 4) as f64).cos()
5669                    });
5670                    (u, v)
5671                })
5672                .collect();
5673            let dense_pairs = family
5674                .assemble_second_directional_derivatives_from_probs(probs.view(), &pairs)
5675                .expect("dense second-directional assembly must succeed");
5676            for (idx, (u, v)) in pairs.iter().enumerate() {
5677                let dense = DenseMatrixHyperOperator {
5678                    matrix: dense_pairs[idx].clone(),
5679                };
5680                let mf = family
5681                    .second_directional_hyper_operator(
5682                        probs.view(),
5683                        u,
5684                        v,
5685                        Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
5686                    )
5687                    .expect("matrix-free second-directional operator must build");
5688                assert_oracle_parity(
5689                    &dense,
5690                    &mf,
5691                    &factor,
5692                    &probe,
5693                    &format!("pair {idx} n={n} p={p} k={k}"),
5694                );
5695            }
5696        }
5697    }
5698
5699    /// Assert dense-vs-matrix-free parity on every consumed surface to ≤1e-10.
5700    fn assert_oracle_parity(
5701        dense: &DenseMatrixHyperOperator,
5702        mf: &MultinomialDirectionalHyperOperator,
5703        factor: &Array2<f64>,
5704        probe: &Array1<f64>,
5705        ctx: &str,
5706    ) {
5707        assert_eq!(dense.dim(), mf.dim(), "{ctx}: dim mismatch");
5708
5709        // Full projected matrix Fᵀ B F — the surface the consumer needs in full.
5710        let pd = dense.projected_matrix(factor);
5711        let pm = mf.projected_matrix(factor);
5712        for ((r, c), &a) in pd.indexed_iter() {
5713            let b = pm[[r, c]];
5714            assert!(
5715                (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
5716                "{ctx}: projected_matrix[{r},{c}] dense {a} != matrix-free {b}"
5717            );
5718        }
5719
5720        // Trace of the projection.
5721        let td = dense.trace_projected_factor(factor);
5722        let tm = mf.trace_projected_factor(factor);
5723        assert!(
5724            (td - tm).abs() <= 1e-10 * (1.0 + td.abs()),
5725            "{ctx}: trace dense {td} != matrix-free {tm}"
5726        );
5727        let tm_from_projection = pm.diag().sum();
5728        assert!(
5729            (tm - tm_from_projection).abs() <= 1e-10 * (1.0 + tm.abs()),
5730            "{ctx}: direct trace {tm} != projected-matrix trace {tm_from_projection}"
5731        );
5732
5733        // Matvec B·v.
5734        let bvd = dense.mul_vec(probe);
5735        let bvm = mf.mul_vec(probe);
5736        for (idx, (&a, &b)) in bvd.iter().zip(bvm.iter()).enumerate() {
5737            assert!(
5738                (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
5739                "{ctx}: mul_vec[{idx}] dense {a} != matrix-free {b}"
5740            );
5741        }
5742
5743        // Dense materialization B.
5744        let dd = dense.to_dense();
5745        let dm = mf.to_dense();
5746        for ((r, c), &a) in dd.indexed_iter() {
5747            let b = dm[[r, c]];
5748            assert!(
5749                (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
5750                "{ctx}: to_dense[{r},{c}] dense {a} != matrix-free {b}"
5751            );
5752        }
5753    }
5754
5755    #[test]
5756    fn new_rejects_k_less_than_two() {
5757        let n = 3;
5758        let y = array![[1.0], [1.0], [1.0]];
5759        let w = Array1::<f64>::ones(n);
5760        let x = Arc::new(Array2::<f64>::ones((n, 1)));
5761        let zero = Array2::<f64>::zeros((1, 1));
5762        let s = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(zero)]);
5763        let err = MultinomialFamily::new(y, w, 1, x, s).expect_err("K = 1 must be rejected");
5764        assert!(err.contains("K"));
5765    }
5766
5767    // ----------------------------------------------------------------------
5768    // Matrix-free joint-Hessian matvec (#347).
5769    //
5770    // The contract: `MultinomialHessianWorkspace::hessian_matvec` /
5771    // `hessian_matvec_into` / `hessian_diagonal` must agree with the dense
5772    // joint Hessian `H = block(X^T W(β) X)` that the workspace also exposes
5773    // through `hessian_dense`, while never materialising the dense matrix on
5774    // the matvec path. The tests below pin three independent angles:
5775    //   1. matvec == dense·v across many directions and a non-trivial β;
5776    //   2. diagonal == dense diagonal bit-for-bit;
5777    //   3. matvec == central finite difference of the −logL gradient, an
5778    //      angle that never touches the Fisher-block assembly at all.
5779    // ----------------------------------------------------------------------
5780
5781    /// Build a `MultinomialFamily` with explicit row weights and a smooth
5782    /// deterministic design / one-hot response so tests are reproducible.
5783    fn family_with_weights(
5784        n_obs: usize,
5785        p: usize,
5786        k: usize,
5787        weights: Array1<f64>,
5788    ) -> MultinomialFamily {
5789        let y = {
5790            let mut y = Array2::<f64>::zeros((n_obs, k));
5791            for i in 0..n_obs {
5792                y[[i, (3 * i + 1) % k]] = 1.0;
5793            }
5794            y
5795        };
5796        let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
5797            0.7 * ((i as f64 + 1.0) * 0.31 + (j as f64) * 0.53).sin() - 0.2 * (j as f64)
5798        }));
5799        let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(
5800            Array2::<f64>::from_shape_fn((p, p), |(i, j)| if i == j { 1.0 } else { 0.0 }),
5801        )]);
5802        MultinomialFamily::new(y, weights, k, design, penalties)
5803            .expect("family_with_weights must construct")
5804    }
5805
5806    /// Stacked block states whose per-class η is `X·β_a`, matching the
5807    /// converged-state contract the workspace consumes.
5808    fn states_at_betas(
5809        family: &MultinomialFamily,
5810        betas: &[Array1<f64>],
5811    ) -> Vec<ParameterBlockState> {
5812        let x = family.design.view();
5813        betas
5814            .iter()
5815            .map(|b| ParameterBlockState {
5816                beta: b.clone(),
5817                eta: x.dot(b),
5818            })
5819            .collect()
5820    }
5821
5822    /// Deterministic, non-trivial per-class coefficient vectors.
5823    fn sample_betas(m: usize, p: usize, scale: f64) -> Vec<Array1<f64>> {
5824        (0..m)
5825            .map(|a| {
5826                Array1::from_shape_fn(p, |i| {
5827                    scale * (0.41 * (a as f64 + 1.0) - 0.23 * (i as f64) + 0.13).sin()
5828                })
5829            })
5830            .collect()
5831    }
5832
5833    /// Stacked −logL gradient `g_{a·P+i} = Σ_n X_{n,i} w_n (p_{n,a} − y_{n,a})`,
5834    /// computed straight from the softmax probabilities — no Fisher block, no
5835    /// `dense_block_xtwx`. Used as the independent finite-difference oracle.
5836    fn neglogl_grad(family: &MultinomialFamily, states: &[ParameterBlockState]) -> Array1<f64> {
5837        let eta = family.collect_eta_matrix(states).expect("eta collect");
5838        let probs = family.row_probabilities(eta.view());
5839        let x = family.design.view();
5840        let n = family.weights.len();
5841        let p = family.design.ncols();
5842        let m = family.active_classes();
5843        let mut g = Array1::<f64>::zeros(m * p);
5844        for a in 0..m {
5845            for i in 0..p {
5846                let mut acc = 0.0_f64;
5847                for row in 0..n {
5848                    acc += x[[row, i]]
5849                        * family.weights[row]
5850                        * (probs[[row, a]] - family.y_one_hot[[row, a]]);
5851                }
5852                g[a * p + i] = acc;
5853            }
5854        }
5855        g
5856    }
5857
5858    fn perturb(betas: &[Array1<f64>], v: &Array1<f64>, factor: f64) -> Vec<Array1<f64>> {
5859        let p = betas[0].len();
5860        betas
5861            .iter()
5862            .enumerate()
5863            .map(|(a, b)| Array1::from_shape_fn(p, |i| b[i] + factor * v[a * p + i]))
5864            .collect()
5865    }
5866
5867    #[test]
5868    fn matrix_free_matvec_matches_dense_across_directions() {
5869        // K = 4 ⇒ M = 3 active classes with genuine off-diagonal coupling.
5870        let n = 13;
5871        let p = 4;
5872        let k = 4;
5873        let family = family_with_weights(
5874            n,
5875            p,
5876            k,
5877            Array1::from_shape_fn(n, |i| 0.5 + 0.5 * ((i as f64) * 0.37).cos().abs()),
5878        );
5879        let m = family.active_classes();
5880        let total = m * p;
5881        let states = states_at_betas(&family, &sample_betas(m, p, 0.8));
5882        let specs = family.build_block_specs();
5883        let ws = family
5884            .exact_newton_joint_hessian_workspace(&states, &specs)
5885            .expect("workspace build")
5886            .expect("workspace present");
5887        let dense = ws.hessian_dense().expect("dense").expect("dense present");
5888
5889        for seed in 0..8usize {
5890            let v = Array1::from_shape_fn(total, |idx| {
5891                ((seed * 31 + idx * 17 + 5) as f64 * 0.123).cos()
5892            });
5893            let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
5894            let dv = dense.dot(&v);
5895            let mut max_abs = 0.0_f64;
5896            let mut scale = 1.0e-300_f64;
5897            for idx in 0..total {
5898                max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
5899                scale = scale.max(dv[idx].abs());
5900            }
5901            assert!(
5902                max_abs <= 1.0e-10 * scale + 1.0e-13,
5903                "seed {seed}: matrix-free matvec deviates from dense by {max_abs} (scale {scale})"
5904            );
5905        }
5906    }
5907
5908    #[test]
5909    fn matrix_free_matvec_does_not_allocate_dense_but_matches_at_extreme_eta() {
5910        // Large |η| drives the softmax to near-degenerate probabilities
5911        // (some p ≈ 1, the rest ≈ 0). The matvec must stay finite and still
5912        // track the dense reference within tight tolerance.
5913        let n = 9;
5914        let p = 3;
5915        let k = 5;
5916        let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
5917        let m = family.active_classes();
5918        let total = m * p;
5919        let states = states_at_betas(&family, &sample_betas(m, p, 12.0));
5920        let specs = family.build_block_specs();
5921        let ws = family
5922            .exact_newton_joint_hessian_workspace(&states, &specs)
5923            .expect("workspace build")
5924            .expect("workspace present");
5925        let dense = ws.hessian_dense().expect("dense").expect("dense present");
5926        let v = Array1::from_shape_fn(total, |idx| ((idx as f64) * 0.91 - 1.0).sin());
5927        let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
5928        let dv = dense.dot(&v);
5929        let mut max_abs = 0.0_f64;
5930        let mut scale = 1.0e-300_f64;
5931        for idx in 0..total {
5932            assert!(mf[idx].is_finite(), "matvec entry {idx} not finite");
5933            max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
5934            scale = scale.max(dv[idx].abs());
5935        }
5936        assert!(
5937            max_abs <= 1.0e-10 * scale + 1.0e-13,
5938            "extreme-η matvec deviates from dense by {max_abs} (scale {scale})"
5939        );
5940    }
5941
5942    #[test]
5943    fn matrix_free_matvec_handles_zero_weight_rows() {
5944        // Zero-weight rows must drop out of both paths identically.
5945        let n = 10;
5946        let p = 3;
5947        let k = 3;
5948        let mut w = Array1::<f64>::ones(n);
5949        w[2] = 0.0;
5950        w[5] = 0.0;
5951        w[9] = 0.0;
5952        let family = family_with_weights(n, p, k, w);
5953        let m = family.active_classes();
5954        let total = m * p;
5955        let states = states_at_betas(&family, &sample_betas(m, p, 0.6));
5956        let specs = family.build_block_specs();
5957        let ws = family
5958            .exact_newton_joint_hessian_workspace(&states, &specs)
5959            .expect("workspace build")
5960            .expect("workspace present");
5961        let dense = ws.hessian_dense().expect("dense").expect("dense present");
5962        let v = Array1::from_shape_fn(total, |idx| (idx as f64 + 0.5).cos());
5963        let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
5964        let dv = dense.dot(&v);
5965        let mut max_abs = 0.0_f64;
5966        let mut scale = 1.0e-300_f64;
5967        for idx in 0..total {
5968            max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
5969            scale = scale.max(dv[idx].abs());
5970        }
5971        assert!(
5972            max_abs <= 1.0e-10 * scale + 1.0e-13,
5973            "zero-weight matvec deviates from dense by {max_abs} (scale {scale})"
5974        );
5975    }
5976
5977    #[test]
5978    fn workspace_gradient_and_loglik_match_family_evaluation_and_prefer_operator() {
5979        // The frozen-β workspace must serve the joint log-likelihood and the
5980        // stacked −logL gradient from its cached probabilities, bit-consistent
5981        // with the family's `exact_newton_joint_gradient_evaluation`, and it
5982        // must declare the Operator source preference so the inner joint-Newton
5983        // routes through the matrix-free H·v contraction instead of assembling
5984        // and factorizing the dense (K−1)P×(K−1)P Hessian every cycle
5985        // (#714 / #722 inner cost).
5986        let n = 11;
5987        let p = 4;
5988        let k = 3;
5989        let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
5990        let m = family.active_classes();
5991        let states = states_at_betas(&family, &sample_betas(m, p, 0.9));
5992        let specs = family.build_block_specs();
5993
5994        let family_eval = family
5995            .exact_newton_joint_gradient_evaluation(&states, &specs)
5996            .expect("family joint gradient eval")
5997            .expect("family joint gradient present");
5998
5999        let ws = family
6000            .exact_newton_joint_hessian_workspace(&states, &specs)
6001            .expect("workspace build")
6002            .expect("workspace present");
6003
6004        assert_eq!(
6005            ws.hessian_source_preference(),
6006            JointHessianSourcePreference::Operator,
6007            "multinomial workspace must prefer the operator (matrix-free) source"
6008        );
6009
6010        let ws_loglik = ws
6011            .joint_log_likelihood_evaluation()
6012            .expect("workspace loglik")
6013            .expect("workspace loglik present");
6014        assert!(
6015            (ws_loglik - family_eval.log_likelihood).abs()
6016                <= 1e-12 * (1.0 + family_eval.log_likelihood.abs()),
6017            "workspace loglik {ws_loglik} != family loglik {}",
6018            family_eval.log_likelihood
6019        );
6020
6021        let ws_grad_eval = ws
6022            .joint_gradient_evaluation()
6023            .expect("workspace gradient eval")
6024            .expect("workspace gradient present");
6025        assert!(
6026            (ws_grad_eval.log_likelihood - family_eval.log_likelihood).abs()
6027                <= 1e-12 * (1.0 + family_eval.log_likelihood.abs()),
6028            "workspace gradient-eval loglik mismatch"
6029        );
6030        assert_eq!(ws_grad_eval.gradient.len(), family_eval.gradient.len());
6031        let mut max_abs = 0.0_f64;
6032        let mut scale = 1.0e-300_f64;
6033        for idx in 0..family_eval.gradient.len() {
6034            max_abs = max_abs.max((ws_grad_eval.gradient[idx] - family_eval.gradient[idx]).abs());
6035            scale = scale.max(family_eval.gradient[idx].abs());
6036        }
6037        assert!(
6038            max_abs <= 1e-10 * scale + 1e-13,
6039            "workspace gradient deviates from family gradient by {max_abs} (scale {scale})"
6040        );
6041    }
6042
6043    #[test]
6044    fn matrix_free_matvec_binary_k_equals_two() {
6045        // K = 2 ⇒ M = 1: no off-diagonal block, H·v reduces to the scalar
6046        // logistic curvature. Guards the degenerate single-active-class arm.
6047        let n = 7;
6048        let p = 3;
6049        let k = 2;
6050        let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
6051        let m = family.active_classes();
6052        assert_eq!(m, 1);
6053        let total = m * p;
6054        let states = states_at_betas(&family, &sample_betas(m, p, 1.1));
6055        let specs = family.build_block_specs();
6056        let ws = family
6057            .exact_newton_joint_hessian_workspace(&states, &specs)
6058            .expect("workspace build")
6059            .expect("workspace present");
6060        let dense = ws.hessian_dense().expect("dense").expect("dense present");
6061        let v = Array1::from_shape_fn(total, |idx| (idx as f64 * 0.7 + 0.2).sin());
6062        let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
6063        let dv = dense.dot(&v);
6064        for idx in 0..total {
6065            assert!(
6066                (mf[idx] - dv[idx]).abs() <= 1.0e-12 * (1.0 + dv[idx].abs()),
6067                "binary matvec entry {idx}: {} vs {}",
6068                mf[idx],
6069                dv[idx]
6070            );
6071        }
6072    }
6073
6074    #[test]
6075    fn matrix_free_matvec_into_matches_owned_return() {
6076        let n = 8;
6077        let p = 3;
6078        let k = 4;
6079        let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
6080        let m = family.active_classes();
6081        let total = m * p;
6082        let states = states_at_betas(&family, &sample_betas(m, p, 0.9));
6083        let specs = family.build_block_specs();
6084        let ws = family
6085            .exact_newton_joint_hessian_workspace(&states, &specs)
6086            .expect("workspace build")
6087            .expect("workspace present");
6088        let v = Array1::from_shape_fn(total, |idx| (idx as f64 * 1.7 - 0.3).cos());
6089        let owned = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
6090        // Pre-fill `out` with garbage to prove the into-variant overwrites it.
6091        let mut out = Array1::from_elem(total, 7.0_f64);
6092        let wrote = ws.hessian_matvec_into(&v, &mut out).expect("matvec_into");
6093        assert!(wrote, "matvec_into must report it wrote a result");
6094        assert_eq!(out, owned, "into-variant must match owned return bitwise");
6095    }
6096
6097    #[test]
6098    fn matrix_free_diagonal_is_bit_identical_to_dense_diag() {
6099        let n = 11;
6100        let p = 4;
6101        let k = 4;
6102        let family = family_with_weights(
6103            n,
6104            p,
6105            k,
6106            Array1::from_shape_fn(n, |i| 0.25 + (i as f64 % 3.0)),
6107        );
6108        let m = family.active_classes();
6109        let total = m * p;
6110        let states = states_at_betas(&family, &sample_betas(m, p, 0.7));
6111        let specs = family.build_block_specs();
6112        let ws = family
6113            .exact_newton_joint_hessian_workspace(&states, &specs)
6114            .expect("workspace build")
6115            .expect("workspace present");
6116        let dense = ws.hessian_dense().expect("dense").expect("dense present");
6117        let diag = ws
6118            .hessian_diagonal()
6119            .expect("diagonal")
6120            .expect("diagonal some");
6121        for idx in 0..total {
6122            // The matrix-free diagonal (`hessian_diagonal`) accumulates
6123            // Σ_row w·p_a(1-p_a)·x_i² directly per coefficient, while the dense
6124            // path builds the full XᵀWX Gram via a different (blocked)
6125            // accumulation order. The two are algebraically identical but the
6126            // distinct summation orders differ in the last ULP, so exact
6127            // bit-for-bit equality is unachievable; assert agreement to a few
6128            // ULP via a relative tolerance instead (gam#846).
6129            let got = diag[idx];
6130            let expected = dense[[idx, idx]];
6131            let tol = 1e-12 * (1.0 + expected.abs());
6132            assert!(
6133                (got - expected).abs() <= tol,
6134                "matrix-free diagonal entry {idx} must equal dense diagonal to a few ULP: \
6135                 got={got} dense={expected} (tol={tol})"
6136            );
6137        }
6138    }
6139
6140    #[test]
6141    fn matrix_free_matvec_matches_gradient_finite_difference() {
6142        // Independent oracle: H = ∂(−logL gradient)/∂β under the canonical
6143        // logit link, so H·v equals the central difference of the −logL
6144        // gradient along v. This path uses only softmax probabilities and
6145        // never calls the Fisher-block assembly the matvec shares with dense.
6146        let n = 12;
6147        let p = 3;
6148        let k = 4;
6149        let family = family_with_weights(
6150            n,
6151            p,
6152            k,
6153            Array1::from_shape_fn(n, |i| 0.4 + 0.3 * ((i as f64) * 0.6).sin().abs()),
6154        );
6155        let m = family.active_classes();
6156        let total = m * p;
6157        let betas = sample_betas(m, p, 0.5);
6158        let states = states_at_betas(&family, &betas);
6159        let specs = family.build_block_specs();
6160        let ws = family
6161            .exact_newton_joint_hessian_workspace(&states, &specs)
6162            .expect("workspace build")
6163            .expect("workspace present");
6164
6165        let v = Array1::from_shape_fn(total, |idx| 0.5 * ((idx as f64 * 1.3 + 0.7).sin()));
6166        let hv = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
6167
6168        let eps = 1.0e-6;
6169        let g_plus = neglogl_grad(
6170            &family,
6171            &states_at_betas(&family, &perturb(&betas, &v, eps)),
6172        );
6173        let g_minus = neglogl_grad(
6174            &family,
6175            &states_at_betas(&family, &perturb(&betas, &v, -eps)),
6176        );
6177        let mut max_abs = 0.0_f64;
6178        let mut scale = 1.0e-300_f64;
6179        for idx in 0..total {
6180            let fd = (g_plus[idx] - g_minus[idx]) / (2.0 * eps);
6181            max_abs = max_abs.max((hv[idx] - fd).abs());
6182            scale = scale.max(fd.abs());
6183        }
6184        assert!(
6185            max_abs <= 1.0e-5 * scale + 1.0e-7,
6186            "matvec vs gradient finite-difference deviates by {max_abs} (scale {scale})"
6187        );
6188    }
6189
6190    // ----------------------------------------------------------------------
6191    // #932 doctrine oracle for the softmax directional / second-directional
6192    // joint-Hessian assembly.
6193    //
6194    // The production generated path builds the per-canonical-axis derivatives of the
6195    // joint softmax Fisher Hessian `H(β) = block(Xᵀ W(β) X)`,
6196    // `W = diag(p) − p pᵀ`, in one fused row sweep
6197    // (`assemble_all_axis_directional_derivatives`,
6198    // `assemble_all_axis_second_directional_derivatives`). Their
6199    // `diag(p)−ppᵀ` coefficients come from the same normalized-softmax
6200    // perturbation expression as the general-direction path. This independent
6201    // finite-difference oracle catches a dropped or mis-weighted coefficient
6202    // (the #736/#947 bug genus), not divergence between production formulas.
6203    //
6204    // MECHANICAL SOURCE (independent of the assembly under test):
6205    //  * `H(β) = exact_newton_joint_hessian(β)` is the STATIC joint Fisher
6206    //    Hessian — the assembly's own zeroth order. Its derivative along the
6207    //    canonical axis `e_{(a0,i0)}` is `∂H/∂β_{a0,i0}`, which we take by a
6208    //    central finite difference of `H` (a quantity that never calls the
6209    //    directional assembly). This pins the FIRST-directional set.
6210    //  * `Hdot[δ](β) = exact_newton_joint_hessian_directional_derivative(β, δ)`
6211    //    via the per-direction `directional_fisher_jet` → `dense_block_xtwx`
6212    //    route (the GENERAL-direction branch, NOT the canonical-axis memo). Its
6213    //    derivative along canonical axis `e_a` is `∂Hdot[δ]/∂β_a`, taken by a
6214    //    central FD of `Hdot[δ]`. This pins the SECOND-directional set against a
6215    //    different assembly than the one under test.
6216    // ----------------------------------------------------------------------
6217
6218    /// Perturb a stacked β set by `factor·X·e_{(a0,i0)}` in the η domain: add
6219    /// `factor` to coefficient `i0` of class `a0` and rebuild the η states.
6220    fn perturb_axis(
6221        family: &MultinomialFamily,
6222        betas: &[Array1<f64>],
6223        a0: usize,
6224        i0: usize,
6225        factor: f64,
6226    ) -> Vec<ParameterBlockState> {
6227        let mut shifted = betas.to_vec();
6228        shifted[a0][i0] += factor;
6229        states_at_betas(family, &shifted)
6230    }
6231
6232    #[test]
6233    fn all_axis_directional_derivatives_match_static_hessian_finite_difference() {
6234        // K = 4 ⇒ M = 3 active classes with genuine off-diagonal softmax
6235        // coupling; p = 3 coefficients per class.
6236        let n = 11;
6237        let p = 3;
6238        let k = 4;
6239        let family = family_with_weights(
6240            n,
6241            p,
6242            k,
6243            Array1::from_shape_fn(n, |i| 0.5 + 0.4 * ((i as f64) * 0.41).sin().abs()),
6244        );
6245        let m = family.active_classes();
6246        let total = m * p;
6247        let betas = sample_betas(m, p, 0.6);
6248        let states = states_at_betas(&family, &betas);
6249        let eta = family.collect_eta_matrix(&states).expect("eta collect");
6250
6251        let hand = family.assemble_all_axis_directional_derivatives(eta.view());
6252        assert_eq!(
6253            hand.len(),
6254            total,
6255            "one directional matrix per canonical axis"
6256        );
6257
6258        let eps = 1.0e-6;
6259        let mut max_rel = 0.0_f64;
6260        for a0 in 0..m {
6261            for i0 in 0..p {
6262                let axis = a0 * p + i0;
6263                let h_plus = family
6264                    .exact_newton_joint_hessian(&perturb_axis(&family, &betas, a0, i0, eps))
6265                    .expect("H+")
6266                    .expect("H+ some");
6267                let h_minus = family
6268                    .exact_newton_joint_hessian(&perturb_axis(&family, &betas, a0, i0, -eps))
6269                    .expect("H-")
6270                    .expect("H- some");
6271                let hand_axis = &hand[axis];
6272                for r in 0..total {
6273                    for c in 0..total {
6274                        let fd = (h_plus[[r, c]] - h_minus[[r, c]]) / (2.0 * eps);
6275                        let scale = fd.abs().max(hand_axis[[r, c]].abs()).max(1.0);
6276                        max_rel = max_rel.max((hand_axis[[r, c]] - fd).abs() / scale);
6277                    }
6278                }
6279            }
6280        }
6281        assert!(
6282            max_rel <= 1.0e-6,
6283            "softmax all-axis directional assembly drifted from the static-Hessian \
6284             finite difference by relative {max_rel:.3e}"
6285        );
6286    }
6287
6288    #[test]
6289    fn all_axis_second_directional_derivatives_match_directional_finite_difference() {
6290        let n = 10;
6291        let p = 3;
6292        let k = 4;
6293        let family = family_with_weights(
6294            n,
6295            p,
6296            k,
6297            Array1::from_shape_fn(n, |i| 0.6 + 0.3 * ((i as f64) * 0.53).cos().abs()),
6298        );
6299        let m = family.active_classes();
6300        let total = m * p;
6301        let betas = sample_betas(m, p, 0.5);
6302        let states = states_at_betas(&family, &betas);
6303        let eta = family.collect_eta_matrix(&states).expect("eta collect");
6304
6305        // Fixed first direction δ (the u-direction), a non-canonical mode so the
6306        // mechanical witness exercises the general directional jet branch.
6307        let delta = Array1::from_shape_fn(total, |idx| 0.4 * ((idx as f64 * 1.7 + 0.3).sin()));
6308
6309        let hand = family
6310            .assemble_all_axis_second_directional_derivatives(eta.view(), &delta)
6311            .expect("second-directional assembly");
6312        assert_eq!(hand.len(), total, "one second-directional matrix per axis");
6313
6314        // Mechanical witness: Hdot[δ](β) by the per-direction jet route, FD'd
6315        // along each canonical axis. Force the GENERAL-direction branch (not the
6316        // canonical-axis memo) — δ is a dense mode, so the branch is taken.
6317        let hdot_at = |st: &[ParameterBlockState]| -> Array2<f64> {
6318            family
6319                .exact_newton_joint_hessian_directional_derivative(st, &delta)
6320                .expect("Hdot")
6321                .expect("Hdot some")
6322        };
6323
6324        let eps = 1.0e-6;
6325        let mut max_rel = 0.0_f64;
6326        for a0 in 0..m {
6327            for i0 in 0..p {
6328                let axis = a0 * p + i0;
6329                let hd_plus = hdot_at(&perturb_axis(&family, &betas, a0, i0, eps));
6330                let hd_minus = hdot_at(&perturb_axis(&family, &betas, a0, i0, -eps));
6331                let hand_axis = &hand[axis];
6332                for r in 0..total {
6333                    for c in 0..total {
6334                        let fd = (hd_plus[[r, c]] - hd_minus[[r, c]]) / (2.0 * eps);
6335                        let scale = fd.abs().max(hand_axis[[r, c]].abs()).max(1.0);
6336                        max_rel = max_rel.max((hand_axis[[r, c]] - fd).abs() / scale);
6337                    }
6338                }
6339            }
6340        }
6341        assert!(
6342            max_rel <= 1.0e-5,
6343            "softmax all-axis second-directional assembly drifted from the directional \
6344             finite difference by relative {max_rel:.3e}"
6345        );
6346    }
6347
6348    /// #753 — a multinomial adapter instance can arm the universal full-span
6349    /// Jeffreys/Firth proper prior so a SEPARATING fit gets finite, bounded
6350    /// curvature instead of drifting to ±∞.
6351    ///
6352    /// `MultinomialFamily` is a `CustomFamily`, so the formula REML entry
6353    /// (`fit_penalized_multinomial_formula` → `fit_custom_family_with_rho_prior`)
6354    /// can fold the term `Φ = ½ log|Z_Jᵀ H Z_J|` into the coupled joint Newton
6355    /// solve through `build_joint_jeffreys_subspace` +
6356    /// `custom_family_joint_jeffreys_term`. Those wrappers are private to
6357    /// `custom_family.rs`, but they do exactly two things this test reproduces
6358    /// verbatim against the multinomial family's own exact joint Hessian and
6359    /// analytic directional derivative:
6360    ///   1. build the full-span basis `Z_J = I` (one identity per block,
6361    ///      stacked) via `jeffreys_subspace_from_penalty`, and
6362    ///   2. evaluate `joint_jeffreys_term(H, Z_J, ∂_β H[·])`.
6363    ///
6364    /// On a CLEANLY SEPARATED, UNPENALIZED multinomial geometry the joint
6365    /// information `H` is near-singular along the separating direction (its
6366    /// smallest eigenvalue collapses toward 0 as the iterate drifts out), the
6367    /// exact MLE-at-infinity pathology #753 is about. The assertions pin that:
6368    ///   * the conditioning gate FIRES (the term is non-trivial — `Φ`, `∇Φ`,
6369    ///     `H_Φ` are not all zero), i.e. the multinomial family is NOT silently
6370    ///     excluded from the universal robustness, and
6371    ///   * the Gauss-Newton curvature `H_Φ` is FINITE and supplies strictly
6372    ///     positive curvature on the separating direction the bare `H` does not —
6373    ///     the `O(1)`-bounding term that makes the penalized Newton iterate
6374    ///     finite (acceptance option (a)).
6375    #[test]
6376    fn separating_multinomial_arms_universal_jeffreys_firth_term() {
6377        use gam_linalg::faer_ndarray::FaerEigh;
6378        use gam_solve::estimate::reml::jeffreys_subspace::{
6379            jeffreys_subspace_from_penalty, joint_jeffreys_term,
6380        };
6381
6382        // K = 3 classes, single covariate that PERFECTLY separates the classes
6383        // by threshold, plus an intercept. Unpenalized (λ = 0, zero penalty), so
6384        // the separating slope direction has a genuine MLE at ±∞.
6385        let n = 60usize;
6386        let k = 3usize;
6387        let p = 2usize; // [intercept, x]
6388        let design = Arc::new(Array2::<f64>::from_shape_fn(
6389            (n, p),
6390            |(row, col)| match col {
6391                0 => 1.0,
6392                _ => -3.0 + 6.0 * (row as f64) / ((n - 1) as f64),
6393            },
6394        ));
6395        let mut y = Array2::<f64>::zeros((n, k));
6396        for row in 0..n {
6397            let x = design[[row, 1]];
6398            let class = if x < -1.0 {
6399                0
6400            } else if x > 1.0 {
6401                1
6402            } else {
6403                2 // reference class occupies the middle band
6404            };
6405            y[[row, class]] = 1.0;
6406        }
6407        // Unpenalized: zero penalty so NO proper wiggliness prior exists on any
6408        // direction — separation is the only thing that could bound the slope.
6409        let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(Array2::<
6410            f64,
6411        >::zeros(
6412            (
6413            p, p,
6414        )
6415        ))]);
6416        let weights = Array1::<f64>::ones(n);
6417        let family = MultinomialFamily::new(y, weights, k, design, penalties)
6418            .expect("separated multinomial family must construct");
6419
6420        let m = family.active_classes();
6421        let total = m * p;
6422
6423        // Drive the iterate well out along the separating slope, the regime the
6424        // screening floor would otherwise leave un-bounded. Large per-class
6425        // slopes ⇒ near-saturated softmax ⇒ near-singular joint information.
6426        let betas: Vec<Array1<f64>> = (0..m)
6427            .map(|a| Array1::from_vec(vec![-300.0, 600.0 * ((a as f64) - 0.5)]))
6428            .collect();
6429        let states = states_at_betas(&family, &betas);
6430
6431        // Family's EXACT coupled joint Hessian at the separating iterate — the
6432        // same payload `custom_family_joint_jeffreys_term` pulls.
6433        let h_joint = family
6434            .exact_newton_joint_hessian(&states)
6435            .expect("joint Hessian eval")
6436            .expect("multinomial exposes an explicit joint Hessian");
6437        assert_eq!(h_joint.dim(), (total, total));
6438
6439        // Confirm the separation pathology: the joint information is genuinely
6440        // near-singular (smallest eigenvalue ≪ largest), the MLE-at-infinity
6441        // direction the Jeffreys term exists to bound.
6442        let (evals, _) = h_joint
6443            .eigh(faer::Side::Lower)
6444            .expect("information eigendecomposition");
6445        let lambda_max = evals.iter().cloned().fold(0.0_f64, f64::max);
6446        let lambda_min = evals.iter().cloned().fold(f64::INFINITY, f64::min);
6447        assert!(
6448            lambda_max > 0.0 && lambda_min / lambda_max < 1.0e-6,
6449            "fixture must be near-separating: λ_min/λ_max = {} (λ_min={lambda_min}, λ_max={lambda_max})",
6450            lambda_min / lambda_max
6451        );
6452
6453        // Full-span basis Z_J = I, block-diagonally stacked exactly as
6454        // `build_joint_jeffreys_subspace` does (each block's span is I_p).
6455        let aggregate = Array2::<f64>::zeros((p, p));
6456        let block_span = jeffreys_subspace_from_penalty(aggregate.view())
6457            .expect("block Jeffreys span")
6458            .columns;
6459        assert_eq!(block_span.dim(), (p, p));
6460        let mut z_joint = Array2::<f64>::zeros((total, total));
6461        for b in 0..m {
6462            for i in 0..p {
6463                for j in 0..p {
6464                    z_joint[[b * p + i, b * p + j]] = block_span[[i, j]];
6465                }
6466            }
6467        }
6468
6469        // Evaluate the universal Jeffreys term against the family's analytic
6470        // directional derivative — the identical closure
6471        // `custom_family_joint_jeffreys_term` constructs.
6472        let (phi, grad_phi, hphi) =
6473            joint_jeffreys_term(h_joint.view(), z_joint.view(), |direction: &Array1<f64>| {
6474                family.exact_newton_joint_hessian_directional_derivative(&states, direction)
6475            })
6476            .expect("multinomial joint Jeffreys term must evaluate");
6477
6478        // The conditioning gate must FIRE on this separating geometry: the
6479        // multinomial family is armed by the universal robustness, not excluded.
6480        let term_active =
6481            phi != 0.0 || grad_phi.iter().any(|v| *v != 0.0) || hphi.iter().any(|v| *v != 0.0);
6482        assert!(
6483            term_active,
6484            "Jeffreys/Firth term must fire on a separating multinomial fit (φ={phi})"
6485        );
6486
6487        // `H_Φ` must be finite everywhere (no inf/NaN leaking from the near-
6488        // singular information).
6489        assert!(
6490            phi.is_finite() && grad_phi.iter().all(|v| v.is_finite()),
6491            "Jeffreys φ/∇φ must be finite (φ={phi})"
6492        );
6493        for v in hphi.iter() {
6494            assert!(v.is_finite(), "H_Φ entry must be finite, got {v}");
6495        }
6496
6497        // The Gauss-Newton curvature `H_Φ` is PSD by construction; on the
6498        // separating direction (the smallest-eigenvalue eigenvector of `H`) it
6499        // must add STRICTLY POSITIVE curvature the bare information lacks — the
6500        // O(1) bound that makes `H + S_λ + H_Φ` SPD and the iterate finite.
6501        let (_, evecs) = h_joint
6502            .eigh(faer::Side::Lower)
6503            .expect("eig for separating direction");
6504        let sep_dir = evecs.column(0).to_owned(); // eigenvector of λ_min
6505        let curv_h = sep_dir.dot(&h_joint.dot(&sep_dir));
6506        let curv_hphi = sep_dir.dot(&hphi.dot(&sep_dir));
6507        assert!(
6508            curv_hphi > 0.0,
6509            "H_Φ must supply positive curvature on the separating direction (got {curv_hphi}; bare H curvature there is {curv_h})"
6510        );
6511        assert!(
6512            curv_hphi.is_finite() && curv_hphi >= curv_h,
6513            "augmented curvature {curv_hphi} must dominate the near-zero bare curvature {curv_h}"
6514        );
6515    }
6516
6517    /// A second-difference penalty on `p` coefficients: `D₂ᵀD₂` where `D₂` is the
6518    /// `(p−2)×p` second-difference operator. Rank `p−2` (nullspace = constants +
6519    /// linears), a realistic smooth-term penalty with a genuine nullspace.
6520    fn second_difference_penalty(p: usize) -> Array2<f64> {
6521        let mut s = Array2::<f64>::zeros((p, p));
6522        for r in 0..p.saturating_sub(2) {
6523            // row of D₂: [.. 1, -2, 1 ..]
6524            let d = [1.0_f64, -2.0, 1.0];
6525            for (a, &da) in d.iter().enumerate() {
6526                for (b, &db) in d.iter().enumerate() {
6527                    s[[r + a, r + b]] += da * db;
6528                }
6529            }
6530        }
6531        s
6532    }
6533
6534    /// gam#1587: the reference-symmetric centered penalty `M ⊗ S` is a symmetric
6535    /// function of all `K` classes, so its quadratic form is identical under
6536    /// every choice of reference class — while the legacy reference-anchored
6537    /// (block-diagonal `Σ_a β_aᵀ S β_a`) penalty genuinely disagrees. This is the
6538    /// pure-algebra core of the fix; the end-to-end fit invariance is verified by
6539    /// `tests/glm/families/multinomial_reference_class_invariant_1587`.
6540    #[test]
6541    fn centered_penalty_is_reference_class_invariant_1587() {
6542        let p = 5usize;
6543        let s = second_difference_penalty(p);
6544        // A fixed set of full per-class smooth coefficients γ_0,γ_1,γ_2 (K=3).
6545        // The softmax depends only on η differences, so the penalized fit must
6546        // not care which class is pinned to η ≡ 0.
6547        let gamma: [Array1<f64>; 3] = [
6548            array![0.4, -0.1, 0.7, 0.2, -0.5],
6549            array![-0.3, 0.8, 0.1, -0.6, 0.25],
6550            array![0.15, 0.05, -0.4, 0.9, -0.2],
6551        ];
6552        let k = 3usize;
6553        let m = k - 1;
6554        let metric = centered_class_metric(m, k);
6555
6556        // For reference class `r`, the active (ALR) coefficients are the two
6557        // non-reference classes' `γ_a − γ_r`. Build the stacked β^{(r)} and
6558        // evaluate both penalties.
6559        let centered_value = |r: usize| -> f64 {
6560            let actives: Vec<usize> = (0..3).filter(|&c| c != r).collect();
6561            let mut beta = Array1::<f64>::zeros(m * p);
6562            for (a, &cls) in actives.iter().enumerate() {
6563                let diff = &gamma[cls] - &gamma[r];
6564                beta.slice_mut(ndarray::s![a * p..(a + 1) * p])
6565                    .assign(&diff);
6566            }
6567            // βᵀ (M ⊗ S) β with block (a,b) = M[a,b]·S.
6568            let mut acc = 0.0;
6569            for a in 0..m {
6570                for b in 0..m {
6571                    let ba = beta.slice(ndarray::s![a * p..(a + 1) * p]);
6572                    let bb = beta.slice(ndarray::s![b * p..(b + 1) * p]);
6573                    acc += metric[[a, b]] * ba.dot(&s.dot(&bb));
6574                }
6575            }
6576            acc
6577        };
6578        let diagonal_value = |r: usize| -> f64 {
6579            let actives: Vec<usize> = (0..3).filter(|&c| c != r).collect();
6580            actives
6581                .iter()
6582                .map(|&cls| {
6583                    let diff = &gamma[cls] - &gamma[r];
6584                    diff.dot(&s.dot(&diff))
6585                })
6586                .sum()
6587        };
6588
6589        let c0 = centered_value(0);
6590        let c1 = centered_value(1);
6591        let c2 = centered_value(2);
6592        assert!(
6593            (c0 - c1).abs() < 1e-12 && (c0 - c2).abs() < 1e-12,
6594            "centered penalty must be reference-invariant: {c0} {c1} {c2}"
6595        );
6596        // And it equals the symmetric CLR form Σ_k (γ_k − γ̄)ᵀ S (γ_k − γ̄).
6597        let mean: Array1<f64> = (&gamma[0] + &gamma[1] + &gamma[2]) / 3.0;
6598        let clr: f64 = gamma
6599            .iter()
6600            .map(|g| {
6601                let c = g - &mean;
6602                c.dot(&s.dot(&c))
6603            })
6604            .sum();
6605        assert!(
6606            (c0 - clr).abs() < 1e-10,
6607            "centered penalty {c0} must equal the CLR form {clr}"
6608        );
6609
6610        // The legacy reference-anchored penalty genuinely DEPENDS on r (the bug).
6611        let d0 = diagonal_value(0);
6612        let d1 = diagonal_value(1);
6613        let d2 = diagonal_value(2);
6614        let diag_spread = (d0 - d1).abs().max((d0 - d2).abs()).max((d1 - d2).abs());
6615        assert!(
6616            diag_spread > 1e-6,
6617            "reference-anchored penalty should differ across references (reproducing the bug); spread {diag_spread}"
6618        );
6619    }
6620
6621    /// `M ⊗ S` is symmetric PSD with the declared nullspace `(K−1)·ns(S)`, the
6622    /// contract `JointPenaltySpec::validate` and the outer pseudo-logdet rely on.
6623    #[test]
6624    fn centered_joint_penalty_spec_is_psd_with_declared_nullspace_1587() {
6625        use gam_linalg::faer_ndarray::FaerEigh;
6626        let p = 5usize;
6627        let s = second_difference_penalty(p); // rank p-2 ⇒ ns(S) = 2
6628        let k = 4usize; // K=4 ⇒ m=3
6629        let m = k - 1;
6630        let metric = centered_class_metric(m, k);
6631        let raw_total = m * p;
6632        let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
6633        for a in 0..m {
6634            for b in 0..m {
6635                for i in 0..p {
6636                    for j in 0..p {
6637                        matrix[[a * p + i, b * p + j]] = metric[[a, b]] * s[[i, j]];
6638                    }
6639                }
6640            }
6641        }
6642        // Symmetric.
6643        for i in 0..raw_total {
6644            for j in 0..raw_total {
6645                assert!((matrix[[i, j]] - matrix[[j, i]]).abs() < 1e-14);
6646            }
6647        }
6648        let (evals, _) = FaerEigh::eigh(&matrix, faer::Side::Lower).expect("eigh");
6649        let mut sorted: Vec<f64> = evals.iter().copied().collect();
6650        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
6651        // PSD: no meaningfully negative eigenvalue.
6652        assert!(sorted[0] > -1e-10, "M⊗S must be PSD; min eig {}", sorted[0]);
6653        // Nullspace dim = (K-1)·ns(S) = 3·2 = 6.
6654        let zeros = sorted.iter().take_while(|&&v| v.abs() < 1e-9).count();
6655        assert_eq!(
6656            zeros,
6657            m * 2,
6658            "nullspace dim must be (K-1)·ns(S); spectrum {sorted:?}"
6659        );
6660    }
6661}