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, BlockWorkingSet, CustomFamily, ExactNewtonJointGradientEvaluation,
66    ExactNewtonJointHessianWorkspace, FamilyEvaluation, JointHessianSourcePreference,
67    ParameterBlockSpec, ParameterBlockState, PenaltyMatrix,
68};
69use crate::vector_response::{
70    MultinomialLogitLikelihood, VectorLikelihood, validate_multinomial_simplex,
71};
72use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix, SymmetricMatrix};
73use gam_math::jet_scalar::{JetScalar, OneSeed, Order2, TwoSeed};
74use gam_math::nested_dual::JetField;
75use gam_problem::HyperOperator;
76use gam_solve::pirls::dense_block_xtwx;
77use ndarray::{Array1, Array2, Array3, ArrayView2};
78use std::sync::{Arc, Mutex};
79
80#[inline]
81fn multinomial_stable_shift(eta: &[f64]) -> f64 {
82    eta.iter().copied().fold(0.0_f64, f64::max)
83}
84
85/// Canonical stable normalization for active logits plus an implicit zero
86/// reference logit. Every probability consumer, including prediction and the
87/// higher-order Fisher schedule, receives its base state from this function.
88/// The returned `(shift, log_centered_denominator)` keeps scalar likelihood
89/// lowerings in the same cancellation-free coordinates without repeating the
90/// exponential pass.
91#[inline(always)]
92pub(crate) fn multinomial_logit_probabilities_into(
93    eta: &[f64],
94    probabilities: &mut [f64],
95) -> (f64, f64) {
96    assert_eq!(probabilities.len(), eta.len() + 1);
97    let shift = multinomial_stable_shift(eta);
98    let active_classes = eta.len();
99    let reference_mass = (-shift).exp();
100    let mut denominator = reference_mass;
101    for (axis, &logit) in eta.iter().enumerate() {
102        let mass = (logit - shift).exp();
103        probabilities[axis] = mass;
104        denominator += mass;
105    }
106    let inverse_denominator = denominator.recip();
107    for probability in &mut probabilities[..active_classes] {
108        *probability *= inverse_denominator;
109    }
110    probabilities[active_classes] = reference_mass * inverse_denominator;
111    (shift, denominator.ln())
112}
113
114/// Production [`gam_math::jet_tower::RowProgram`] for one reference-coded
115/// multinomial-logit row.
116///
117/// Active-class logits are the `M` primaries and class `M` is the implicit
118/// reference with logit zero. The generic row NLL is the mechanical tower
119/// oracle for the retained normalized-softmax/Fisher lowerings in this module;
120/// production parity tests invoke this type directly rather than restating its
121/// expression under `cfg(test)`.
122#[derive(Clone, Copy, Debug)]
123pub struct MultinomialLogitRowProgram<'row> {
124    eta: &'row [f64],
125    response: &'row [f64],
126    weight: f64,
127}
128
129impl<'row> MultinomialLogitRowProgram<'row> {
130    /// Construct one validated row. `eta` contains the active-class logits and
131    /// `response` contains the complete simplex row, including the implicit
132    /// reference class in its last slot.
133    pub fn new(eta: &'row [f64], response: &'row [f64], weight: f64) -> Result<Self, String> {
134        let active_classes = eta.len();
135        if active_classes == 0 {
136            return Err("MultinomialLogitRowProgram requires at least one active class".into());
137        }
138        if response.len() != active_classes + 1 {
139            return Err(format!(
140                "MultinomialLogitRowProgram response length {} must equal active classes + reference = {}",
141                response.len(),
142                active_classes + 1,
143            ));
144        }
145        if !weight.is_finite() || weight < 0.0 {
146            return Err(format!(
147                "MultinomialLogitRowProgram weight must be finite and non-negative, got {weight}"
148            ));
149        }
150        if let Some((axis, value)) = eta
151            .iter()
152            .copied()
153            .enumerate()
154            .find(|(_, value)| !value.is_finite())
155        {
156            return Err(format!(
157                "MultinomialLogitRowProgram eta[{axis}] must be finite, got {value}"
158            ));
159        }
160        if let Some((class, value)) = response
161            .iter()
162            .copied()
163            .enumerate()
164            .find(|(_, value)| !value.is_finite() || *value < 0.0)
165        {
166            return Err(format!(
167                "MultinomialLogitRowProgram response[{class}] must be finite and non-negative, got {value}"
168            ));
169        }
170        let response_mass: f64 = response.iter().sum();
171        let simplex_tolerance = 1.0e-10 * (1.0 + response.len() as f64);
172        if (response_mass - 1.0).abs() > simplex_tolerance {
173            return Err(format!(
174                "MultinomialLogitRowProgram response must sum to one, got {response_mass}"
175            ));
176        }
177        Ok(Self {
178            eta,
179            response,
180            weight,
181        })
182    }
183
184    fn require_row(row: usize) -> Result<(), String> {
185        if row != 0 {
186            return Err(format!(
187                "MultinomialLogitRowProgram holds exactly one row; got row {row}"
188            ));
189        }
190        Ok(())
191    }
192
193    /// Stable shift shared by the semantic row expression and its compiled
194    /// probability/Fisher schedule. Including the reference logit zero keeps
195    /// every exponential argument non-positive.
196    #[inline]
197    fn stable_shift(&self) -> f64 {
198        multinomial_stable_shift(self.eta)
199    }
200
201    /// The one semantic row NLL over an arbitrary scalar field. Constants enter
202    /// through `constant`, allowing the same body to evaluate plain `f64` and
203    /// every fixed Taylor scalar selected by [`gam_math::jet_tower::RowProgram`].
204    ///
205    /// Centering the response term before adding the reference-class share avoids
206    /// the catastrophic `shift - observed_logit` cancellation that a conventional
207    /// `shift + log(sum(exp(eta-shift))) - y'eta` spelling suffers in saturated
208    /// tails. The identity uses `sum(response) = 1`:
209    ///
210    /// `NLL/w = log(D) - sum_active y_a(eta_a-shift) + y_ref*shift`.
211    fn eval_expression<S: JetField>(&self, primaries: &[S], constant: impl Fn(f64) -> S) -> S {
212        assert_eq!(primaries.len(), self.eta.len());
213        if self.weight == 0.0 {
214            return constant(0.0);
215        }
216        let shift = self.stable_shift();
217        let mut denominator = constant((-shift).exp());
218        let mut centered_response = constant(0.0);
219        for (axis, primary) in primaries.iter().enumerate() {
220            let centered = primary.add(&constant(-shift));
221            let exponential_value = centered.value().exp();
222            let exponential = centered.compose_unary([
223                exponential_value,
224                exponential_value,
225                exponential_value,
226                exponential_value,
227                exponential_value,
228            ]);
229            denominator = denominator.add(&exponential);
230            let response = self.response[axis];
231            if response != 0.0 {
232                centered_response = centered_response.add(&centered.scale(response));
233            }
234        }
235        let denominator_value = denominator.value();
236        let reciprocal = 1.0 / denominator_value;
237        let log_denominator = denominator.compose_unary([
238            denominator_value.ln(),
239            reciprocal,
240            -reciprocal * reciprocal,
241            2.0 * reciprocal * reciprocal * reciprocal,
242            -6.0 * reciprocal * reciprocal * reciprocal * reciprocal,
243        ]);
244        let reference_response = self.response[self.eta.len()];
245        let nll = log_denominator.sub(&centered_response);
246        let nll = if reference_response == 0.0 {
247            nll
248        } else {
249            nll.add(&constant(reference_response * shift))
250        };
251        nll.scale(self.weight)
252    }
253
254    /// Stable scalar NLL from the exact semantic expression.
255    #[inline]
256    pub(crate) fn negative_log_likelihood(&self) -> f64 {
257        self.eval_expression(self.eta, |value| value)
258    }
259
260    /// Compile the semantic normalized-softmax row into probabilities. The
261    /// returned shift and centered log-denominator use the same representation as
262    /// [`Self::eval_expression`]; no probability clamp or alternate tail policy
263    /// exists anywhere in the live likelihood.
264    #[inline(always)]
265    pub(crate) fn probabilities_into(&self, probabilities: &mut [f64]) -> (f64, f64) {
266        assert_eq!(probabilities.len(), self.response.len());
267        multinomial_logit_probabilities_into(self.eta, probabilities)
268    }
269
270    /// Scalar structure-compiled lowering of [`Self::eval_expression`] from a
271    /// normalization already produced for gradient/Hessian channels.
272    #[inline]
273    fn negative_log_likelihood_from_normalization(
274        &self,
275        shift: f64,
276        log_centered_denominator: f64,
277    ) -> f64 {
278        if self.weight == 0.0 {
279            return 0.0;
280        }
281        let mut centered_response = 0.0_f64;
282        for (axis, &response) in self.response[..self.eta.len()].iter().enumerate() {
283            if response != 0.0 {
284                centered_response += response * (self.eta[axis] - shift);
285            }
286        }
287        let reference_response = self.response[self.eta.len()];
288        let reference_term = if reference_response == 0.0 {
289            0.0
290        } else {
291            reference_response * shift
292        };
293        self.weight * (log_centered_denominator - centered_response + reference_term)
294    }
295
296    /// Structure-compiled value/gradient lowering of the semantic row. The
297    /// gradient is the NLL gradient; callers needing the log-likelihood negate
298    /// both channels. `inline(always)` so the const-hinted V/G/H shapes see
299    /// through to the normalization loops.
300    #[inline(always)]
301    pub(crate) fn value_gradient_into(
302        &self,
303        probabilities: &mut [f64],
304        gradient: &mut [f64],
305    ) -> f64 {
306        let active_classes = self.eta.len();
307        assert_eq!(gradient.len(), active_classes);
308        let (shift, log_centered_denominator) = self.probabilities_into(probabilities);
309        for axis in 0..active_classes {
310            gradient[axis] = self.weight * (probabilities[axis] - self.response[axis]);
311        }
312        self.negative_log_likelihood_from_normalization(shift, log_centered_denominator)
313    }
314
315    /// Diagonal-only structure-compiled Hessian lowering. This preserves the
316    /// O(M) preconditioner path without reintroducing a second softmax formula.
317    pub(crate) fn hessian_diagonal_into(&self, probabilities: &mut [f64], diagonal: &mut [f64]) {
318        let active_classes = self.eta.len();
319        assert_eq!(diagonal.len(), active_classes);
320        self.probabilities_into(probabilities);
321        for axis in 0..active_classes {
322            let probability = probabilities[axis];
323            diagonal[axis] = self.weight * probability * (1.0 - probability);
324        }
325    }
326
327    /// Structure-compiled value/gradient/Hessian lowering of the semantic row.
328    /// `gradient` is the NLL gradient and `hessian` is row-major. Both are
329    /// mechanically determined by the normalized masses produced above.
330    ///
331    /// Small class counts route through const-hinted instantiations of the
332    /// SAME body ([`Self::value_gradient_hessian_shaped`]): the release cell
333    /// showed the dynamic-length codegen losing ~15% to the fully unrolled
334    /// generic jet tower at `M ≤ 3` purely on loop/bounds overhead, so the
335    /// one structure-compiled formula is monomorphized at the shapes where
336    /// that overhead is a measurable fraction of the row cost. There is no
337    /// second formula and no alternate lowering — only a compile-time trip
338    /// count for the identical arithmetic.
339    pub(crate) fn value_gradient_hessian_into(
340        &self,
341        probabilities: &mut [f64],
342        gradient: &mut [f64],
343        hessian: &mut [f64],
344    ) -> f64 {
345        match self.eta.len() {
346            1 => self.value_gradient_hessian_shaped::<1>(probabilities, gradient, hessian),
347            2 => self.value_gradient_hessian_shaped::<2>(probabilities, gradient, hessian),
348            3 => self.value_gradient_hessian_shaped::<3>(probabilities, gradient, hessian),
349            4 => self.value_gradient_hessian_shaped::<4>(probabilities, gradient, hessian),
350            _ => self.value_gradient_hessian_shaped::<0>(probabilities, gradient, hessian),
351        }
352    }
353
354    /// The single V/G/H body behind [`Self::value_gradient_hessian_into`].
355    /// `M_HINT = 0` is the runtime-length instantiation; a nonzero hint pins
356    /// `active_classes` to a compile-time constant (checked, then used as the
357    /// trip count) so the loops unroll and the bounds checks vanish.
358    #[inline(always)]
359    fn value_gradient_hessian_shaped<const M_HINT: usize>(
360        &self,
361        probabilities: &mut [f64],
362        gradient: &mut [f64],
363        hessian: &mut [f64],
364    ) -> f64 {
365        let active_classes = if M_HINT == 0 {
366            self.eta.len()
367        } else {
368            assert_eq!(self.eta.len(), M_HINT);
369            M_HINT
370        };
371        assert_eq!(gradient.len(), active_classes);
372        assert_eq!(hessian.len(), active_classes * active_classes);
373        let value = self.value_gradient_into(probabilities, gradient);
374        for row in 0..active_classes {
375            let probability_row = probabilities[row];
376            for column in 0..active_classes {
377                let probability_column = probabilities[column];
378                hessian[row * active_classes + column] = self.weight
379                    * if row == column {
380                        probability_row * (1.0 - probability_column)
381                    } else {
382                        -probability_row * probability_column
383                    };
384            }
385        }
386        value
387    }
388}
389
390impl<const M: usize> gam_math::jet_tower::RowProgram<M> for MultinomialLogitRowProgram<'_> {
391    fn n_rows(&self) -> usize {
392        1
393    }
394
395    fn primaries(&self, row: usize) -> Result<[f64; M], String> {
396        Self::require_row(row)?;
397        self.eta.try_into().map_err(|_| {
398            format!(
399                "MultinomialLogitRowProgram has {} active logits but RowProgram dimension is {M}",
400                self.eta.len()
401            )
402        })
403    }
404
405    fn eval<S: JetScalar<M>>(&self, row: usize, p: &[S; M]) -> Result<S, String> {
406        Self::require_row(row)?;
407        if self.eta.len() != M {
408            return Err(format!(
409                "MultinomialLogitRowProgram has {} active logits but RowProgram dimension is {M}",
410                self.eta.len()
411            ));
412        }
413        Ok(self.eval_expression(p, S::constant))
414    }
415}
416
417/// Nilpotent coefficient selected from the canonical multinomial perturbation
418/// program below. `OneSeed<0>` selects the first directional derivative;
419/// `TwoSeed<0>` selects the mixed second directional derivative. There are no
420/// primary axes because this program differentiates only along supplied
421/// coefficient-space directions.
422///
423/// The pair of coefficient-space directions a Fisher perturbation is seeded
424/// along. First-directional seeds consume only `u`; the mixed second-directional
425/// seed consumes both. Bundling the pair keeps a single `seed` signature across
426/// both perturbation orders without forcing either impl to carry an unused
427/// positional argument.
428#[derive(Clone, Copy)]
429struct FisherDirection {
430    u: f64,
431    v: f64,
432}
433
434trait FisherPerturbation: JetScalar<0> {
435    type Channels: Copy;
436    const CONTIGUOUS_FULL: bool;
437
438    fn seed(direction: FisherDirection) -> Self;
439    fn coefficient(&self) -> f64;
440    fn from_channels(base: f64, channels: Self::Channels) -> Self;
441    fn normalized_channels(
442        probability: f64,
443        direction_u: f64,
444        mass: &Self,
445        inverse: &Self,
446    ) -> Self::Channels;
447    fn store_channels(channels: Self::Channels, weight: f64) -> Self::Channels;
448    fn fisher_weight(weight: f64) -> f64;
449    fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
450    where
451        F: Fn(usize) -> (f64, f64, Self);
452}
453
454impl FisherPerturbation for OneSeed<0> {
455    type Channels = f64;
456    const CONTIGUOUS_FULL: bool = true;
457
458    #[inline(always)]
459    fn seed(direction: FisherDirection) -> Self {
460        Self {
461            base: <Order2<0> as JetScalar<0>>::constant(0.0),
462            eps: <Order2<0> as JetScalar<0>>::constant(direction.u),
463        }
464    }
465
466    #[inline(always)]
467    fn coefficient(&self) -> f64 {
468        gam_math::nested_dual::JetField::value(&self.eps)
469    }
470
471    #[inline(always)]
472    fn from_channels(base: f64, channels: Self::Channels) -> Self {
473        Self {
474            base: <Order2<0> as JetScalar<0>>::constant(base),
475            eps: <Order2<0> as JetScalar<0>>::constant(channels),
476        }
477    }
478
479    #[inline(always)]
480    fn normalized_channels(
481        probability: f64,
482        direction_u: f64,
483        _: &Self,
484        inverse: &Self,
485    ) -> Self::Channels {
486        probability * (direction_u + gam_math::nested_dual::JetField::value(&inverse.eps))
487    }
488
489    #[inline(always)]
490    fn store_channels(channels: Self::Channels, weight: f64) -> Self::Channels {
491        channels * weight
492    }
493
494    #[inline(always)]
495    fn fisher_weight(_: f64) -> f64 {
496        1.0
497    }
498
499    #[inline(always)]
500    fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
501    where
502        F: Fn(usize) -> (f64, f64, Self),
503    {
504        let mut eps_coefficient = 0.0;
505        for a in 0..m {
506            eps_coefficient += gam_math::nested_dual::JetField::value(&perturbed_mass(a).2.eps);
507        }
508        Self {
509            base: <Order2<0> as JetScalar<0>>::constant(1.0),
510            eps: <Order2<0> as JetScalar<0>>::constant(eps_coefficient),
511        }
512    }
513}
514
515impl FisherPerturbation for TwoSeed<0> {
516    type Channels = [f64; 3];
517    const CONTIGUOUS_FULL: bool = false;
518
519    #[inline(always)]
520    fn seed(direction: FisherDirection) -> Self {
521        Self {
522            base: <Order2<0> as JetScalar<0>>::constant(0.0),
523            eps: <Order2<0> as JetScalar<0>>::constant(direction.u),
524            del: <Order2<0> as JetScalar<0>>::constant(direction.v),
525            eps_del: <Order2<0> as JetScalar<0>>::constant(0.0),
526        }
527    }
528
529    #[inline(always)]
530    fn coefficient(&self) -> f64 {
531        gam_math::nested_dual::JetField::value(&self.eps_del)
532    }
533
534    #[inline(always)]
535    fn from_channels(base: f64, channels: Self::Channels) -> Self {
536        Self {
537            base: <Order2<0> as JetScalar<0>>::constant(base),
538            eps: <Order2<0> as JetScalar<0>>::constant(channels[0]),
539            del: <Order2<0> as JetScalar<0>>::constant(channels[1]),
540            eps_del: <Order2<0> as JetScalar<0>>::constant(channels[2]),
541        }
542    }
543
544    #[inline(always)]
545    fn normalized_channels(_: f64, _: f64, mass: &Self, inverse: &Self) -> Self::Channels {
546        let normalized = gam_math::nested_dual::JetField::mul(mass, inverse);
547        [
548            gam_math::nested_dual::JetField::value(&normalized.eps),
549            gam_math::nested_dual::JetField::value(&normalized.del),
550            gam_math::nested_dual::JetField::value(&normalized.eps_del),
551        ]
552    }
553
554    #[inline(always)]
555    fn store_channels(channels: Self::Channels, _: f64) -> Self::Channels {
556        channels
557    }
558
559    #[inline(always)]
560    fn fisher_weight(weight: f64) -> f64 {
561        weight
562    }
563
564    #[inline(always)]
565    fn denominator<F>(m: usize, perturbed_mass: &F) -> Self
566    where
567        F: Fn(usize) -> (f64, f64, Self),
568    {
569        let mut denominator = Self::constant(1.0);
570        for a in 0..m {
571            let (probability, _, mass) = perturbed_mass(a);
572            denominator = gam_math::nested_dual::JetField::add(
573                &denominator,
574                &gam_math::nested_dual::JetField::sub(&mass, &Self::constant(probability)),
575            );
576        }
577        denominator
578    }
579}
580
581#[inline(always)]
582fn fisher_entry<S: FisherPerturbation>(
583    probability_a: S,
584    probability_b: S,
585    diagonal: bool,
586    output_weight: f64,
587) -> f64 {
588    let negative_product = gam_math::nested_dual::JetField::neg(
589        &gam_math::nested_dual::JetField::mul(&probability_a, &probability_b),
590    );
591    let entry = if diagonal {
592        gam_math::nested_dual::JetField::add(&probability_a, &negative_product)
593    } else {
594        negative_product
595    };
596    gam_math::nested_dual::JetField::scale(&entry, output_weight).coefficient()
597}
598
599#[inline(always)]
600fn write_static_fisher<S: FisherPerturbation, F: Fn(usize) -> f64, const M: usize>(
601    probability: &F,
602    normalized: &[S::Channels],
603    fisher: &mut [f64],
604    output_weight: f64,
605) {
606    for a in 0..M {
607        let pa = S::from_channels(probability(a), normalized[a]);
608        fisher[a * M + a] = fisher_entry(pa, pa, true, output_weight);
609        for b in (a + 1)..M {
610            let pb = S::from_channels(probability(b), normalized[b]);
611            let coefficient = fisher_entry(pa, pb, false, output_weight);
612            fisher[a * M + b] = coefficient;
613            fisher[b * M + a] = coefficient;
614        }
615    }
616}
617
618#[derive(Clone, Copy, Eq, PartialEq)]
619enum FisherOutputSchedule {
620    SymmetricTriangle,
621    ContiguousFull,
622}
623
624const AVX2_WITHOUT_AVX512: bool = cfg!(all(target_arch = "x86_64", target_feature = "avx2"))
625    && !cfg!(all(target_arch = "x86_64", target_feature = "avx512f"));
626
627/// Select a storage schedule for the same elementwise [`fisher_entry`]
628/// expression. First-order M=32 favors contiguous rows on AVX2-only targets,
629/// while AVX-512 favors symmetric triangular writes; larger first-order blocks
630/// amortize the full-row arithmetic on every target. Mixed-second output stays
631/// triangular. The associated order and target-feature constants erase the
632/// inactive schedule during monomorphization.
633#[inline(always)]
634fn fisher_output_schedule<S: FisherPerturbation>(m: usize) -> FisherOutputSchedule {
635    if S::CONTIGUOUS_FULL && (m >= 64 || (m == 32 && AVX2_WITHOUT_AVX512)) {
636        FisherOutputSchedule::ContiguousFull
637    } else {
638        FisherOutputSchedule::SymmetricTriangle
639    }
640}
641
642/// Evaluate the one canonical active-class softmax/Fisher expression
643///
644/// `p_a(delta) = p_a exp(delta_a) / (1 + sum_c p_c (exp(delta_c) - 1))`
645///
646/// and `F_ab(delta) = weight * (indicator(a=b) p_a(delta) -
647/// p_a(delta) p_b(delta))`, then select the requested nilpotent coefficient.
648/// The implicit reference class is exactly the constant mass in the leading
649/// `1`; at the base point the denominator is bit-exactly one. The exponential
650/// and reciprocal derivative stacks are supplied at their fixed base points
651/// zero and one, so this performs no transcendental calls. Instantiating the
652/// same expression at `OneSeed<0>` or `TwoSeed<0>` yields every live first- and
653/// second-directional Fisher path without a dense class-axis derivative tower.
654/// Only live nilpotent coefficients survive between phases: one contiguous
655/// weighted first channel or three mixed-second channels. The generated output
656/// lowering specializes the common Fisher entry at `M=2,3,8,32`; at first
657/// order M=32 selects an ISA-shaped triangular or contiguous schedule, and
658/// M>=64 uses contiguous full rows. Mixed-second and arbitrary-width output
659/// retain the same triangular expression. These are storage/loop lowerings of
660/// this expression, not independent derivative formulas.
661#[inline(always)]
662fn softmax_fisher_perturbation<S: FisherPerturbation>(
663    m: usize,
664    weight: f64,
665    probability: impl Fn(usize) -> f64,
666    direction_u: impl Fn(usize) -> f64,
667    direction_v: impl Fn(usize) -> f64,
668    normalized: &mut [S::Channels],
669    fisher: &mut [f64],
670) {
671    assert_eq!(normalized.len(), m);
672    assert_eq!(fisher.len(), m * m);
673    let perturbed_mass = |a| {
674        let pa = probability(a);
675        let direction_u = direction_u(a);
676        let delta = S::seed(FisherDirection {
677            u: direction_u,
678            v: direction_v(a),
679        });
680        let mass = gam_math::nested_dual::JetField::scale(
681            &gam_math::nested_dual::JetField::compose_unary(&delta, [1.0; 5]),
682            pa,
683        );
684        (pa, direction_u, mass)
685    };
686    let denominator = S::denominator(m, &perturbed_mass);
687    let inverse =
688        gam_math::nested_dual::JetField::compose_unary(&denominator, [1.0, -1.0, 2.0, -6.0, 24.0]);
689    for (a, channels) in normalized.iter_mut().enumerate() {
690        let (pa, direction_u, mass) = perturbed_mass(a);
691        *channels = S::store_channels(
692            S::normalized_channels(pa, direction_u, &mass, &inverse),
693            weight,
694        );
695    }
696    let output_weight = S::fisher_weight(weight);
697    let lifted = |a| S::from_channels(probability(a), normalized[a]);
698    if m == 2 {
699        let p0 = lifted(0);
700        let p1 = lifted(1);
701        fisher[0] = fisher_entry(p0, p0, true, output_weight);
702        let off = fisher_entry(p0, p1, false, output_weight);
703        fisher[1] = off;
704        fisher[2] = off;
705        fisher[3] = fisher_entry(p1, p1, true, output_weight);
706        return;
707    }
708    if m == 3 {
709        let p0 = lifted(0);
710        let p1 = lifted(1);
711        let p2 = lifted(2);
712        fisher[0] = fisher_entry(p0, p0, true, output_weight);
713        let off01 = fisher_entry(p0, p1, false, output_weight);
714        fisher[1] = off01;
715        fisher[3] = off01;
716        let off02 = fisher_entry(p0, p2, false, output_weight);
717        fisher[2] = off02;
718        fisher[6] = off02;
719        fisher[4] = fisher_entry(p1, p1, true, output_weight);
720        let off12 = fisher_entry(p1, p2, false, output_weight);
721        fisher[5] = off12;
722        fisher[7] = off12;
723        fisher[8] = fisher_entry(p2, p2, true, output_weight);
724        return;
725    }
726    if m == 8 {
727        write_static_fisher::<S, _, 8>(&probability, normalized, fisher, output_weight);
728        return;
729    }
730    let output_schedule = fisher_output_schedule::<S>(m);
731    if m == 32 && output_schedule == FisherOutputSchedule::SymmetricTriangle {
732        write_static_fisher::<S, _, 32>(&probability, normalized, fisher, output_weight);
733        return;
734    }
735    if output_schedule == FisherOutputSchedule::ContiguousFull {
736        for a in 0..m {
737            let pa = lifted(a);
738            let row_start = a * m;
739            for b in 0..m {
740                fisher[row_start + b] = fisher_entry(pa, lifted(b), false, output_weight);
741            }
742            fisher[row_start + a] = fisher_entry(pa, pa, true, output_weight);
743        }
744        return;
745    }
746    for a in 0..m {
747        let pa = lifted(a);
748        fisher[a * m + a] = fisher_entry(pa, pa, true, output_weight);
749        for b in (a + 1)..m {
750            let coefficient = fisher_entry(pa, lifted(b), false, output_weight);
751            fisher[a * m + b] = coefficient;
752            fisher[b * m + a] = coefficient;
753        }
754    }
755}
756
757/// Numerical rank of a symmetric PSD penalty matrix, using the SAME relative
758/// zero classification as [`gam_problem::JointPenaltySpec::validate`]
759/// (`tol = 100·p·ε·max|eig|`), so the `nullspace_dim` a joint-spec builder
760/// declares from this rank always agrees with the spectrum the validator
761/// measures. A caller-declared structural nullity cannot be used for that
762/// purpose: identifiability-absorbed smooth penalties carry more
763/// numerical-zero directions than their structural claim (which is why the
764/// family no longer carries one).
765pub(crate) fn measured_penalty_rank(s: &Array2<f64>) -> Result<usize, String> {
766    let p = s.nrows();
767    if p == 0 {
768        return Ok(0);
769    }
770    use gam_linalg::faer_ndarray::FaerEigh;
771    let (eigenvalues, _) = FaerEigh::eigh(s, faer::Side::Lower)
772        .map_err(|e| format!("penalty rank eigendecomposition failed: {e}"))?;
773    let max_abs = eigenvalues
774        .iter()
775        .fold(0.0_f64, |acc, &ev| acc.max(ev.abs()));
776    let tol = 100.0 * (p as f64) * f64::EPSILON * max_abs;
777    Ok(eigenvalues.iter().filter(|&&ev| ev > tol).count())
778}
779
780/// The reference-symmetric class-space metric `M = I_m − J_m/K` (`m = K−1`
781/// active classes, `J` = all-ones), the closed-form CLR whitening factor of
782/// the softmax gauge (gam#1587). Symmetric positive-definite with eigenvalues
783/// `1` (multiplicity `m−1`) and `1/K` (once).
784pub(crate) fn centered_class_metric(m: usize, k: usize) -> Array2<f64> {
785    let inv_k = 1.0 / k as f64;
786    let mut metric = Array2::<f64>::from_elem((m, m), -inv_k);
787    for a in 0..m {
788        metric[[a, a]] += 1.0;
789    }
790    metric
791}
792
793/// Joint-coupled multinomial-logit family with shared design and shared
794/// smoothing penalty across active classes.
795///
796/// # Block layout
797///
798/// `K − 1` parameter blocks, indexed `a = 0..K-1`, each carrying coefficient
799/// vector `β_a ∈ ℝ^P`. Class `K − 1` is the reference (`β_{K-1} ≡ 0`) and
800/// does not appear in the block list.
801///
802/// # Invariants
803///
804/// * `y_one_hot.dim() == (N, K)`, with `K = total_classes ≥ 2`.
805/// * `weights.len() == N`, finite and non-negative.
806/// * `design.nrows() == N`, `design.ncols() == P`.
807/// * every penalty in `penalties` has shape `(P, P)` (symmetric, PSD).
808///
809/// All are validated by [`MultinomialFamily::new`].
810#[derive(Clone, Debug)]
811pub struct MultinomialFamily {
812    /// Categorical response matrix `Y ∈ ℝ^{N × K}`. Each row must be a point on
813    /// the probability simplex (`y_c ≥ 0`, `Σ_c y_c = 1`): a one-hot indicator
814    /// or a label-smoothed probability vector. Rows whose mass departs from 1
815    /// are rejected by [`MultinomialFamily::new`] — the softmax residual and
816    /// Fisher block are the derivatives of `Σ_c y_c log p_c` only under the
817    /// simplex constraint. Column `K − 1` is the reference class.
818    pub y_one_hot: Array2<f64>,
819    /// Per-row weights `w ∈ ℝ^N`, finite and non-negative.
820    pub weights: Array1<f64>,
821    /// Total class count `K ≥ 2`. Active classes are `0..K-1`; class
822    /// `K − 1` is the reference.
823    pub total_classes: usize,
824    /// Shared design matrix `X ∈ ℝ^{N × P}`, identical across all active
825    /// classes. Carried as `Arc<Array2<f64>>` so the per-block specs and the
826    /// family share storage with zero copies.
827    pub design: Arc<Array2<f64>>,
828    /// Per-smooth-term penalty components, each a `P × P` operator expressed in
829    /// block-local form (`PenaltyMatrix::Blockwise` embedding the term's local
830    /// `S_t` at its `col_range` within the shared `P`-column coefficient
831    /// space). **Every active class block receives this entire list**, so the
832    /// outer REML/LAML loop selects an *independent* smoothing parameter per
833    /// `(class, term)` — matching mgcv/VGAM per-term smoothing. The full
834    /// block-replicated penalty is `I_{K-1} ⊗ (Σ_t λ_{a,t} S_t)`; pre-summing
835    /// the terms (one fused λ per class) is exactly the multi-term fusion that
836    /// over-smooths one term while under-smoothing another (#561). Carried as
837    /// `Arc<Vec<…>>` so per-block specs share storage with zero copies.
838    pub penalties: Arc<Vec<PenaltyMatrix>>,
839    /// Cached likelihood evaluator. Constructed once with the same row
840    /// weights as `weights` and reused across every `evaluate` call.
841    likelihood: MultinomialLogitLikelihood,
842    /// Memo for the FULL set of canonical-axis joint-Hessian directional
843    /// derivatives `{ Hdot[e_k] }_{k=0..(K-1)·P}` at one frozen `β`.
844    ///
845    /// The Tier-B Jeffreys/Firth term (`joint_jeffreys_term`) drives the inner
846    /// loop `for k in 0..p { hessian_dir(e_k) }`, calling
847    /// [`Self::exact_newton_joint_hessian_directional_derivative`] once PER
848    /// canonical axis at the SAME `block_states`. Each call independently
849    /// recomputed the full `(N,K)` softmax and re-formed a generic
850    /// `dense_block_xtwx` Gram — `O(p)` redundant softmax passes per term, and
851    /// the term itself is rebuilt at every accepted inner-Newton β and every
852    /// outer LAML eval (#715/#722/#753: the multinomial Firth grind). This memo
853    /// assembles the WHOLE axis set in one softmax pass the first time an axis
854    /// is requested at a given β, then serves every subsequent axis (the rest of
855    /// that Jeffreys loop) from the cache. Keyed on an η fingerprint so a moved
856    /// β recomputes; a single-slot cache suffices because the Jeffreys loop
857    /// requests all `p` axes consecutively before β changes.
858    ///
859    /// `Arc<Mutex<…>>` (interior mutability) because the family is shared
860    /// `&self` and `Clone`; the per-axis derivative is a pure function of the
861    /// frozen `β`, so a stale clone simply recomputes — never returns a wrong
862    /// value. Cheap clones share the slot.
863    axis_derivative_cache: Arc<Mutex<Option<AxisDerivativeCache>>>,
864    /// Whether this family instance contributes the full-span Jeffreys/Firth
865    /// correction to the coupled custom-family solve.
866    ///
867    /// The formula REML entry (`fit_penalized_multinomial_formula`) arms this
868    /// CONDITIONALLY (#715/#753): attempt 1 fits with it disarmed (the unbiased
869    /// criterion — no Firth shrinkage toward the uniform simplex on interior
870    /// data); on separation evidence (failed solve, non-finite or saturated
871    /// logits) the fit is re-run once with it armed, because a penalty-null
872    /// direction `v` (`Sv = 0`) under softmax saturation has `(H + S_λ)v → 0`
873    /// for EVERY ρ — only a proper prior on that quotient-null subspace can
874    /// bound it, never a smoothing parameter.
875    use_joint_jeffreys_term: bool,
876    /// Warm-start seed `log λ` for the reference-symmetric joint smoothing
877    /// penalties (gam#1587). The formula REML driver overrides this from its
878    /// `init_lambda` so the joint-penalty outer ρ starts at the same seed the
879    /// per-block path used historically; the outer loop then selects the true
880    /// optimum. Defaults to `0.0` (`λ = 1`).
881    initial_log_lambda: f64,
882    /// Optional PER-SPEC warm-start seeds for the joint smoothing penalties,
883    /// overriding the shared `initial_log_lambda` (one entry per joint spec, in
884    /// the builders' term-major spec order). This is how a caller follows the
885    /// outer refusal's "resume by seeding the outer search at rho_checkpoint"
886    /// hint for a joint-penalty family — the checkpoint is a PER-SPEC ρ vector
887    /// a single shared seed cannot express — and how fixed-ρ diagnostics pin
888    /// the joint λs when probing the criterion surface (#2349).
889    joint_initial_log_lambdas: Option<Vec<f64>>,
890}
891
892/// One frozen-`β` snapshot of every canonical-axis joint-Hessian directional
893/// derivative, shared across the `p` sequential per-axis requests the Tier-B
894/// Jeffreys loop makes at that `β` (see [`MultinomialFamily::axis_derivative_cache`]).
895#[derive(Clone, Debug)]
896struct AxisDerivativeCache {
897    /// Fingerprint of the stacked per-class `η` the derivatives were built at.
898    eta_key: EtaFingerprint,
899    /// `Hdot[e_k]` for every canonical axis `k = a·P + i`, laid out in the same
900    /// output-major flat order as the joint Hessian.
901    derivatives: Vec<Array2<f64>>,
902}
903
904/// Cheap, exact fingerprint of a stacked `(N, M)` η matrix: its raw `f64` bit
905/// patterns hashed. Two identical `β` snapshots produce identical η bit-for-bit
906/// (the Jeffreys loop never perturbs β between axis requests), so this keys the
907/// single-slot axis-derivative memo without storing the whole η.
908#[derive(Clone, Debug, PartialEq, Eq)]
909struct EtaFingerprint {
910    rows: usize,
911    cols: usize,
912    hash: u64,
913}
914
915impl EtaFingerprint {
916    fn of(eta: ArrayView2<'_, f64>) -> Self {
917        use std::hash::{Hash, Hasher};
918        let mut hasher = std::collections::hash_map::DefaultHasher::new();
919        let (rows, cols) = eta.dim();
920        rows.hash(&mut hasher);
921        cols.hash(&mut hasher);
922        for &v in eta.iter() {
923            v.to_bits().hash(&mut hasher);
924        }
925        EtaFingerprint {
926            rows,
927            cols,
928            hash: hasher.finish(),
929        }
930    }
931}
932
933impl MultinomialFamily {
934    /// Total number of active blocks, `M = K − 1`.
935    pub const fn active_classes(&self) -> usize {
936        self.total_classes - 1
937    }
938
939    /// Validate inputs and construct the family.
940    ///
941    /// All shape and finiteness invariants are checked here so the
942    /// `CustomFamily` methods can rely on pre-validated geometry.
943    pub fn new(
944        y_one_hot: Array2<f64>,
945        weights: Array1<f64>,
946        total_classes: usize,
947        design: Arc<Array2<f64>>,
948        penalties: Arc<Vec<PenaltyMatrix>>,
949    ) -> Result<Self, String> {
950        if total_classes < 2 {
951            return Err(format!(
952                "MultinomialFamily requires K ≥ 2 classes (got {total_classes})"
953            ));
954        }
955        let (n, k) = y_one_hot.dim();
956        if k != total_classes {
957            return Err(format!(
958                "MultinomialFamily: y_one_hot has {k} columns but total_classes = {total_classes}"
959            ));
960        }
961        if weights.len() != n {
962            return Err(format!(
963                "MultinomialFamily: weights length {} != N = {n}",
964                weights.len()
965            ));
966        }
967        for (i, &v) in weights.iter().enumerate() {
968            if !(v.is_finite() && v >= 0.0) {
969                return Err(format!(
970                    "MultinomialFamily: weights[{i}] must be finite and non-negative (got {v})"
971                ));
972            }
973        }
974        if design.nrows() != n {
975            return Err(format!(
976                "MultinomialFamily: design has {} rows, expected {n}",
977                design.nrows()
978            ));
979        }
980        let p = design.ncols();
981        for (t, penalty) in penalties.iter().enumerate() {
982            if penalty.shape() != (p, p) {
983                return Err(format!(
984                    "MultinomialFamily: penalties[{t}] shape {:?} != (P, P) = ({p}, {p})",
985                    penalty.shape()
986                ));
987            }
988            for ((i, j), &v) in penalty.to_dense().indexed_iter() {
989                if !v.is_finite() {
990                    return Err(format!(
991                        "MultinomialFamily: penalties[{t}][{i},{j}] must be finite (got {v})"
992                    ));
993                }
994            }
995        }
996        validate_multinomial_simplex(y_one_hot.view(), "MultinomialFamily")
997            .map_err(|e| e.to_string())?;
998        for ((i, j), &v) in design.indexed_iter() {
999            if !v.is_finite() {
1000                return Err(format!(
1001                    "MultinomialFamily: design[{i},{j}] must be finite (got {v})"
1002                ));
1003            }
1004        }
1005
1006        // Likelihood owns its own copy of the row weights so the family is
1007        // self-contained — `evaluate` does not need to refresh it.
1008        let likelihood = MultinomialLogitLikelihood::with_classes(total_classes)
1009            .map_err(|e| format!("MultinomialFamily: {e}"))?
1010            .with_row_weights(weights.clone())
1011            .map_err(|e| format!("MultinomialFamily: {e}"))?;
1012
1013        Ok(Self {
1014            y_one_hot,
1015            weights,
1016            total_classes,
1017            design,
1018            penalties,
1019            likelihood,
1020            axis_derivative_cache: Arc::new(Mutex::new(None)),
1021            use_joint_jeffreys_term: true,
1022            initial_log_lambda: 0.0,
1023            joint_initial_log_lambdas: None,
1024        })
1025    }
1026
1027    /// Select whether this multinomial adapter instance contributes the
1028    /// full-span Jeffreys/Firth correction.
1029    pub fn with_joint_jeffreys_term(mut self, enabled: bool) -> Self {
1030        self.use_joint_jeffreys_term = enabled;
1031        self
1032    }
1033
1034    /// Seed the warm-start `log λ` carried into the reference-symmetric joint
1035    /// smoothing penalties (gam#1587). The formula REML driver sets this from its
1036    /// `init_lambda` so the joint-penalty outer ρ starts at the same seed the
1037    /// per-block path used historically; the outer loop then selects the optimum.
1038    pub fn with_initial_log_lambda(mut self, log_lambda: f64) -> Self {
1039        self.initial_log_lambda = log_lambda;
1040        self
1041    }
1042
1043    /// Seed PER-SPEC warm-start `log λ` values for the joint smoothing
1044    /// penalties, in the builders' term-major spec order (equivariant carrier:
1045    /// `s = t·K + c`; shared centered carrier: `s = t`). Overrides the shared
1046    /// [`Self::with_initial_log_lambda`] seed entry-by-entry; the spec builders
1047    /// reject a wrong length. This is the resume path for a joint-penalty
1048    /// `rho_checkpoint` and the fixed-ρ pin for criterion diagnostics (#2349).
1049    pub fn with_joint_initial_log_lambdas(mut self, seeds: Vec<f64>) -> Self {
1050        self.joint_initial_log_lambdas = Some(seeds);
1051        self
1052    }
1053
1054    /// Per-spec joint warm-start seed: the override entry when present, else
1055    /// the shared `initial_log_lambda`.
1056    fn joint_seed(&self, spec_index: usize) -> f64 {
1057        self.joint_initial_log_lambdas
1058            .as_ref()
1059            .and_then(|seeds| seeds.get(spec_index))
1060            .copied()
1061            .unwrap_or(self.initial_log_lambda)
1062    }
1063
1064    /// Validate an override seed vector against the joint-spec count the
1065    /// builder is about to produce.
1066    fn validate_joint_seed_len(&self, expected: usize, carrier: &str) -> Result<(), String> {
1067        match self.joint_initial_log_lambdas.as_ref() {
1068            Some(seeds) if seeds.len() != expected => Err(format!(
1069                "multinomial {carrier} carrier: joint_initial_log_lambdas has {} entries, \
1070                 expected {expected} (one per joint spec, term-major)",
1071                seeds.len()
1072            )),
1073            _ => Ok(()),
1074        }
1075    }
1076
1077    /// Build the canonical block specs for this family.
1078    ///
1079    /// One [`ParameterBlockSpec`] per active class, all sharing the same
1080    /// design (zero-copy through `Arc<Array2<f64>>`) and an independent
1081    /// `PenaltyMatrix::Dense` copy of `S`. The `gauge_priority` is set so
1082    /// that the active class **closest to the reference** owns shared
1083    /// affine / null-space directions: class `a` gets priority
1084    /// `100 + (M − a)`. Class `0` (farthest from the reference) is the most
1085    /// likely to retain a shared direction in canonicalisation; class
1086    /// `M − 1` is the least likely. This matches the task's
1087    /// "descending priorities" gauge convention.
1088    ///
1089    /// `initial_log_lambdas` is initialised to zeros (one entry per penalty
1090    /// term per block: each block carries one `λ_{a,t}` per smooth term `t`).
1091    /// Callers that want a custom warm start override per-block before passing
1092    /// to `fit_custom_family_with_rho_prior`.
1093    pub fn build_block_specs(&self) -> Vec<ParameterBlockSpec> {
1094        let m = self.active_classes();
1095        (0..m)
1096            .map(|a| {
1097                let priority = 100u8.saturating_add(u8::try_from(m - a).unwrap_or(u8::MAX));
1098                // Each active class drives a *separate* softmax channel
1099                // `η_a = X β_a`. The K−1 blocks share the identical design `X`,
1100                // but they are **not** gauge-redundant aliases: the true joint
1101                // Jacobian is block-diagonal `blkdiag(X, …, X)` with full rank
1102                // `(K−1)·P`. Supplying an `AdditiveBlockJacobian` that places
1103                // block `a`'s design in its own output channel routes
1104                // canonicalisation through the channel-aware identifiability
1105                // audit (one output per class). Without it the flat audit
1106                // assembles `[X | X | … | X]` over the same N rows, mistakes the
1107                // repeated columns for aliases, and strips every block past
1108                // `class_0` to width 0 — the failure in #363.
1109                //
1110                // The per-class blocks attach NO smooth penalty: the sole
1111                // smoothing carrier is the permutation-equivariant per-class
1112                // centered joint family `λ_{t,c}·(C_cᵀC_c ⊗ S_t)` (see
1113                // `equivariant_class_penalty_specs`). Penalizing the ALR
1114                // contrasts β_a here would re-anchor smoothness to the
1115                // arbitrary reference class (#1587) — and attaching both
1116                // carriers would double-count. Heterogeneous per-class
1117                // smoothness (#1855) survives as the per-class λ_{t,c} on the
1118                // gauge-free centered functions.
1119                let mut spec = ParameterBlockSpec {
1120                    name: format!("class_{a}"),
1121                    design: DesignMatrix::Dense(DenseDesignMatrix::from(self.design.clone())),
1122                    offset: Array1::<f64>::zeros(self.design.nrows()),
1123                    penalties: Vec::new(),
1124                    nullspace_dims: Vec::new(),
1125                    initial_log_lambdas: Array1::<f64>::zeros(0),
1126                    initial_beta: None,
1127                    gauge_priority: priority,
1128                    jacobian_callback: None,
1129                    stacked_design: None,
1130                    stacked_offset: None,
1131                };
1132                spec.jacobian_callback = Some(Arc::new(AdditiveBlockJacobian {
1133                    design: (*self.design).clone(),
1134                    own_output: a,
1135                    n_family_outputs: m,
1136                }));
1137                spec
1138            })
1139            .collect()
1140    }
1141
1142    /// Total stacked-coefficient dimension `(K − 1) · P`.
1143    pub fn beta_flat_dim(&self) -> usize {
1144        self.active_classes() * self.design.ncols()
1145    }
1146
1147    /// Build the reference-symmetric ("centered") full-width smoothing
1148    /// penalties `λ_t · (M ⊗ S_t)`, one per smooth term `t`, in raw stacked
1149    /// (class-major) coordinates `[β_0; …; β_{K-2}]` (gam#1587).
1150    ///
1151    /// `M = I_{K-1} − J_{K-1}/K` is the closed-form CLR whitening metric of the
1152    /// softmax class gauge (the multinomial analogue of the resolved ALR
1153    /// sibling #1549). The quadratic form `βᵀ (M ⊗ S_t) β` equals the symmetric
1154    /// CLR penalty `Σ_{k=0}^{K-1} β̃_{k}ᵀ S_t β̃_{k}` over centered coefficients
1155    /// `β̃_k = β_k − (1/K)Σ_b β_b` (`β_{K-1} ≡ 0`), a symmetric function of all
1156    /// `K` classes — so the penalized fit no longer depends on which class is
1157    /// the arbitrary softmax reference. Block `(a, b)` of the returned
1158    /// `(M·P)×(M·P)` matrix is `M[a,b]·S_t`; `M` is SPD (eigenvalues `1` with
1159    /// multiplicity `K−2` and `1/K` once), so each `M ⊗ S_t` is PSD with
1160    /// `nullspace_dim = (K−1)·nullspace_dim(S_t)`.
1161    ///
1162    /// Every spec carries the per-term precision label `multinomial_term_{t}`
1163    /// so the outer loop ties one shared `λ_t` across all classes (the gauge
1164    /// the centered metric requires; an untied per-(class,term) `λ` is itself a
1165    /// second source of reference dependence).
1166    pub fn centered_joint_penalty_specs(
1167        &self,
1168    ) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
1169        let m = self.active_classes();
1170        let k = self.total_classes;
1171        let p = self.design.ncols();
1172        let metric = centered_class_metric(m, k);
1173        let raw_total = m * p;
1174        self.validate_joint_seed_len(self.penalties.len(), "shared centered")?;
1175        self.penalties
1176            .iter()
1177            .enumerate()
1178            .map(|(t, pen)| {
1179                let s_t = pen.to_dense();
1180                let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
1181                for a in 0..m {
1182                    for b in 0..m {
1183                        let scale = metric[[a, b]];
1184                        for i in 0..p {
1185                            for j in 0..p {
1186                                matrix[[a * p + i, b * p + j]] = scale * s_t[[i, j]];
1187                            }
1188                        }
1189                    }
1190                }
1191                // rank(M ⊗ S_t) = m · rank(S_t); measure rank(S_t) with the
1192                // validator's own zero classification (a structural nullity
1193                // claim understates the numerical nullity for
1194                // identifiability-absorbed smooths).
1195                let rank_s = measured_penalty_rank(&s_t)
1196                    .map_err(|e| format!("multinomial centered penalty term {t}: {e}"))?;
1197                Ok(gam_problem::JointPenaltySpec {
1198                    label: Some(format!("multinomial_term_{t}")),
1199                    matrix,
1200                    initial_log_lambda: self.joint_seed(t),
1201                    nullspace_dim: raw_total - m * rank_s,
1202                })
1203            })
1204            .collect()
1205    }
1206
1207    /// Build the permutation-EQUIVARIANT heterogeneous smoothing penalties:
1208    /// for each smooth term `t`, `K` per-class penalties
1209    /// `λ_{t,c} · γ_cᵀ S_t γ_c` on the CENTERED class functions
1210    /// `γ_c = β_c − (1/K)Σ_b β_b` (with `β_ref ≡ 0`), one λ per class —
1211    /// including the softmax reference class.
1212    ///
1213    /// This is the resolution of the #1587 (reference invariance) vs #1855
1214    /// (heterogeneous per-class smoothness) tension. The reverted per-block
1215    /// carrier penalized the ALR contrasts `β_a = γ_a − γ_ref`, whose
1216    /// "per-class" smoothness is an artifact of which class is the baseline
1217    /// (the family of diagonal ALR precisions is not closed under reference
1218    /// changes). Penalizing the centered functions is reference-free by
1219    /// construction: relabeling classes permutes the (γ_c, λ_{t,c}) pairs
1220    /// together, so the fitted probabilities after label alignment are
1221    /// identical, while REML still selects genuinely heterogeneous per-class
1222    /// smoothness (a wiggly class takes a small λ_c, an easy class shrinks its
1223    /// centered deviation toward the mean function).
1224    ///
1225    /// In stacked ALR coordinates `[β_0; …; β_{m−1}]` (`m = K−1`), class `c`'s
1226    /// centering row is `C_a = e_aᵀ − 𝟙ᵀ/K` for an active class and
1227    /// `C_ref = −𝟙ᵀ/K` for the reference, so spec `(t, c)` carries the PSD
1228    /// rank-`rank(S_t)` matrix `(C_cᵀC_c) ⊗ S_t`. With all `λ_{t,c}` equal the
1229    /// sum collapses exactly to the shared centered metric:
1230    /// `Σ_c C_cᵀC_c = I − J/K = M`, so this family strictly generalizes
1231    /// [`Self::centered_joint_penalty_specs`].
1232    ///
1233    /// `K = 2` is the degenerate case: `γ_ref = −γ_0`, both centered functions
1234    /// have identical wiggliness, and the two per-class metrics are
1235    /// proportional (only `λ_0 + λ_1` would be identified). The shared
1236    /// centered spec is the correct model there, so this builder returns it.
1237    pub fn equivariant_class_penalty_specs(
1238        &self,
1239    ) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
1240        let m = self.active_classes();
1241        let k = self.total_classes;
1242        let p = self.design.ncols();
1243        if k <= 2 {
1244            return self.centered_joint_penalty_specs();
1245        }
1246        let raw_total = m * p;
1247        self.validate_joint_seed_len(self.penalties.len() * k, "equivariant per-class")?;
1248        let mut specs = Vec::with_capacity(self.penalties.len() * k);
1249        for (t, pen) in self.penalties.iter().enumerate() {
1250            let s_t = pen.to_dense();
1251            // rank(C_cᵀC_c ⊗ S_t) = 1 · rank(S_t). The rank must agree with
1252            // the spectrum the joint-penalty validator measures (a structural
1253            // nullity claim understates the numerical nullity for
1254            // identifiability-absorbed smooths), so measure it with the
1255            // validator's own relative classification.
1256            let rank_s = measured_penalty_rank(&s_t)
1257                .map_err(|e| format!("multinomial equivariant penalty term {t}: {e}"))?;
1258            let nullspace_dim = raw_total - rank_s;
1259            for c in 0..k {
1260                // Centering row for class c over the m active coordinates.
1261                let row: Vec<f64> = (0..m)
1262                    .map(|b| {
1263                        let indicator = if c == b { 1.0 } else { 0.0 };
1264                        indicator - 1.0 / (k as f64)
1265                    })
1266                    .collect();
1267                let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
1268                for a in 0..m {
1269                    for b in 0..m {
1270                        let scale = row[a] * row[b];
1271                        if scale == 0.0 {
1272                            continue;
1273                        }
1274                        for i in 0..p {
1275                            for j in 0..p {
1276                                matrix[[a * p + i, b * p + j]] = scale * s_t[[i, j]];
1277                            }
1278                        }
1279                    }
1280                }
1281                specs.push(gam_problem::JointPenaltySpec {
1282                    label: Some(format!("multinomial_term_{t}_class_{c}")),
1283                    matrix,
1284                    initial_log_lambda: self.joint_seed(t * k + c),
1285                    nullspace_dim,
1286                });
1287            }
1288        }
1289        Ok(specs)
1290    }
1291
1292    fn specs_match_workspace_shape(&self, specs: &[ParameterBlockSpec]) -> bool {
1293        let n = self.weights.len();
1294        let p = self.design.ncols();
1295        specs.len() == self.active_classes()
1296            && specs.iter().all(|spec| {
1297                spec.design.nrows() == n
1298                    && spec.design.ncols() == p
1299                    && spec.offset.len() == n
1300                    && spec.stacked_design.is_none()
1301                    && spec.stacked_offset.is_none()
1302                    && spec.initial_log_lambdas.len() == self.penalties.len()
1303                    && spec.penalties.len() == self.penalties.len()
1304            })
1305    }
1306
1307    /// Reshape the K-1 per-block `ParameterBlockState.eta` slices into the
1308    /// `(N, M)` matrix the likelihood expects. Validates lengths.
1309    fn collect_eta_matrix(
1310        &self,
1311        block_states: &[ParameterBlockState],
1312    ) -> Result<Array2<f64>, String> {
1313        let m = self.active_classes();
1314        validate_block_count::<String>("MultinomialFamily", m, block_states.len())?;
1315        let n = self.weights.len();
1316        let mut eta = Array2::<f64>::zeros((n, m));
1317        for (a, state) in block_states.iter().enumerate() {
1318            if state.eta.len() != n {
1319                return Err(format!(
1320                    "MultinomialFamily block {a} eta length {} != N = {n}",
1321                    state.eta.len()
1322                ));
1323            }
1324            for row in 0..n {
1325                eta[[row, a]] = state.eta[row];
1326            }
1327        }
1328        Ok(eta)
1329    }
1330
1331    /// Evaluate likelihood, per-row Fisher block, and per-row residual at
1332    /// the current `η`. Centralises the softmax-driven kernel so every
1333    /// downstream assembly (gradient, dense Hessian, directional derivative)
1334    /// reads from the same source.
1335    fn evaluate_row_kernels(
1336        &self,
1337        eta: ArrayView2<'_, f64>,
1338    ) -> Result<(f64, Array3<f64>, Array2<f64>), String> {
1339        let (log_lik, grad_eta_logl, fisher) = self
1340            .likelihood
1341            .value_gradient_hessian(eta, self.y_one_hot.view())
1342            .map_err(|error| error.to_string())?;
1343        Ok((log_lik, fisher, grad_eta_logl))
1344    }
1345
1346    /// Assemble the per-block gradient `∂(−log L)/∂β_a = X^T (p_a − y_a)`
1347    /// and the per-block dense Hessian `X^T diag_n(w_n · p_a(1 − p_a)) X`
1348    /// (= the block-diagonal piece of `−∇²log L`).
1349    ///
1350    /// Off-diagonal block coupling (`X^T diag_n(−w_n p_a p_b) X` for
1351    /// `a ≠ b`) lives in [`Self::exact_newton_joint_hessian`] — see the
1352    /// `ExactNewton` working-set contract on [`BlockWorkingSet`].
1353    fn assemble_block_diagonal_working_sets(
1354        &self,
1355        fisher: &Array3<f64>,
1356        grad_eta_logl: &Array2<f64>,
1357    ) -> Result<Vec<BlockWorkingSet>, String> {
1358        let n = self.weights.len();
1359        let p = self.design.ncols();
1360        let m = self.active_classes();
1361        let design_view = self.design.view();
1362
1363        let mut sets = Vec::with_capacity(m);
1364        for a in 0..m {
1365            // Gradient of −log L wrt β_a: −X^T (y − p)_a = X^T (p − y)_a.
1366            let mut grad = Array1::<f64>::zeros(p);
1367            for i in 0..p {
1368                let mut acc = 0.0_f64;
1369                for row in 0..n {
1370                    acc += design_view[[row, i]] * (-grad_eta_logl[[row, a]]);
1371                }
1372                grad[i] = acc;
1373            }
1374            // Dense block-diagonal Hessian: X^T diag(W_aa) X.
1375            let mut hess = Array2::<f64>::zeros((p, p));
1376            for row in 0..n {
1377                let w_aa = fisher[[row, a, a]];
1378                if w_aa == 0.0 {
1379                    continue;
1380                }
1381                for i in 0..p {
1382                    let xi = design_view[[row, i]];
1383                    if xi == 0.0 {
1384                        continue;
1385                    }
1386                    let scaled = w_aa * xi;
1387                    for j in 0..p {
1388                        hess[[i, j]] += scaled * design_view[[row, j]];
1389                    }
1390                }
1391            }
1392            // Symmetrise to cancel any accumulator drift.
1393            for i in 0..p {
1394                for j in (i + 1)..p {
1395                    let avg = 0.5 * (hess[[i, j]] + hess[[j, i]]);
1396                    hess[[i, j]] = avg;
1397                    hess[[j, i]] = avg;
1398                }
1399            }
1400            sets.push(BlockWorkingSet::ExactNewton {
1401                gradient: grad,
1402                hessian: SymmetricMatrix::Dense(hess),
1403            });
1404        }
1405        Ok(sets)
1406    }
1407
1408    /// Assemble the full joint stacked Hessian `H ∈ ℝ^{(M·P) × (M·P)}` via
1409    /// the canonical [`dense_block_xtwx`] helper. The ordering matches
1410    /// `flat[a · P + i] = β[i, a]` — output-major.
1411    fn assemble_joint_hessian(&self, fisher: &Array3<f64>) -> Result<Array2<f64>, String> {
1412        dense_block_xtwx(self.design.view(), fisher.view(), None)
1413            .map_err(|e| format!("MultinomialFamily joint Hessian assembly: {e}"))
1414    }
1415
1416    /// Stacked log-likelihood gradient `∂log L / ∂β_a = X^T (y − p)_a`,
1417    /// laid out in the same output-major flat order used by
1418    /// [`Self::assemble_joint_hessian`].
1419    fn assemble_joint_gradient(&self, grad_eta_logl: &Array2<f64>) -> Array1<f64> {
1420        let n = self.weights.len();
1421        let p = self.design.ncols();
1422        let m = self.active_classes();
1423        let design_view = self.design.view();
1424        let mut out = Array1::<f64>::zeros(m * p);
1425        for a in 0..m {
1426            for i in 0..p {
1427                let mut acc = 0.0_f64;
1428                for row in 0..n {
1429                    acc += design_view[[row, i]] * grad_eta_logl[[row, a]];
1430                }
1431                out[a * p + i] = acc;
1432            }
1433        }
1434        out
1435    }
1436
1437    /// Joint log-likelihood and stacked gradient evaluated from cached softmax
1438    /// probabilities, without re-collecting η or re-running the row kernels.
1439    ///
1440    /// `eta` and `probs_full` are the frozen row program's logits and `(N, K)`
1441    /// normalized masses. The value is re-evaluated through the canonical stable
1442    /// row expression (probabilities can underflow to exact zero, so taking their
1443    /// logarithm is not a valid tail representation); the gradient reuses the
1444    /// cached normalized masses. The gradient of `log L` wrt the active blocks is
1445    /// `∂log L/∂β_a = X^T (w ⊙ (y − p))_a`, laid out output-major to match
1446    /// [`Self::assemble_joint_hessian`]. Reused by the frozen-β workspace so the
1447    /// inner joint-Newton gradient load and line-search log-likelihood reads
1448    /// share the same cached probabilities as the matrix-free `H·v` contraction.
1449    fn joint_loglik_and_gradient_from_probs(
1450        &self,
1451        eta: ArrayView2<'_, f64>,
1452        probs_full: ArrayView2<'_, f64>,
1453    ) -> Result<(f64, Array1<f64>), String> {
1454        let n = self.weights.len();
1455        let p = self.design.ncols();
1456        let m = self.active_classes();
1457        let k = self.total_classes;
1458        let design_view = self.design.view();
1459        assert_eq!(eta.dim(), (n, m));
1460        assert_eq!(probs_full.dim(), (n, k));
1461        let mut log_lik = 0.0_f64;
1462        let mut eta_row = vec![0.0_f64; m];
1463        let mut response_row = vec![0.0_f64; k];
1464        for row in 0..n {
1465            let w = self.weights[row];
1466            if w == 0.0 {
1467                continue;
1468            }
1469            for axis in 0..m {
1470                eta_row[axis] = eta[[row, axis]];
1471            }
1472            for class in 0..k {
1473                response_row[class] = self.y_one_hot[[row, class]];
1474            }
1475            let program = MultinomialLogitRowProgram::new(&eta_row, &response_row, w)
1476                .map_err(|error| format!("invalid frozen multinomial row {row}: {error}"))?;
1477            log_lik -= program.negative_log_likelihood();
1478        }
1479        let mut grad = Array1::<f64>::zeros(m * p);
1480        for a in 0..m {
1481            for i in 0..p {
1482                let mut acc = 0.0_f64;
1483                for row in 0..n {
1484                    let resid =
1485                        self.weights[row] * (self.y_one_hot[[row, a]] - probs_full[[row, a]]);
1486                    acc += design_view[[row, i]] * resid;
1487                }
1488                grad[a * p + i] = acc;
1489            }
1490        }
1491        Ok((log_lik, grad))
1492    }
1493
1494    /// Apply a coefficient-space direction `d_β` to the design to obtain
1495    /// the per-row η-direction `(N × M)` matrix
1496    /// `d_η[n, a] = (X · d_β_a)[n]`.
1497    fn d_eta_from_d_beta(&self, d_beta_flat: &Array1<f64>) -> Result<Array2<f64>, String> {
1498        let p = self.design.ncols();
1499        let m = self.active_classes();
1500        let n = self.design.nrows();
1501        if d_beta_flat.len() != m * p {
1502            return Err(format!(
1503                "MultinomialFamily direction length {} != (K-1)·P = {}",
1504                d_beta_flat.len(),
1505                m * p
1506            ));
1507        }
1508        let mut d_eta = Array2::<f64>::zeros((n, m));
1509        let design_view = self.design.view();
1510        for a in 0..m {
1511            for row in 0..n {
1512                let mut acc = 0.0_f64;
1513                for i in 0..p {
1514                    acc += design_view[[row, i]] * d_beta_flat[a * p + i];
1515                }
1516                d_eta[[row, a]] = acc;
1517            }
1518        }
1519        Ok(d_eta)
1520    }
1521
1522    /// Compute the per-row softmax probabilities `p[n, c]` over all `K`
1523    /// classes. The reference class column lives at index `K − 1`.
1524    fn row_probabilities(&self, eta: ArrayView2<'_, f64>) -> Array2<f64> {
1525        self.likelihood.probabilities(eta)
1526    }
1527
1528    /// Matrix-free joint Hessian–vector product `H·v` for the softmax
1529    /// curvature `H = block( X^T W(β) X )`, written into `out` in
1530    /// `O(N·(K-1)·P)` without ever materialising the
1531    /// `(K-1)P × (K-1)P` dense Hessian.
1532    ///
1533    /// Mathematically identical to
1534    /// `assemble_joint_hessian(hess_block(η)).dot(v)`; the result agrees with
1535    /// the dense path up to floating-point reassociation of the row sums. The
1536    /// contraction exploits the rank structure of the per-row Fisher block
1537    /// `W_{n,a,b} = w_n (δ_ab p_{n,a} − p_{n,a} p_{n,b})` so the off-diagonal
1538    /// `−p_a p_b` coupling never materialises:
1539    ///
1540    /// ```text
1541    ///   (X v_b)_n      = Σ_j X_{n,j} v_{b·P+j}            [step 1]
1542    ///   s_n            = Σ_b p_{n,b} (X v_b)_n            [step 2a]
1543    ///   r_{n,a}        = w_n p_{n,a} ( (X v_a)_n − s_n )  [step 2b]
1544    ///   (H v)_{a·P+i}  = Σ_n X_{n,i} r_{n,a}              [step 3]
1545    /// ```
1546    ///
1547    /// `probs_full` is the cached `(N, K)` softmax probability matrix at the
1548    /// frozen β; only the `K − 1` active columns are read (the reference
1549    /// column `K − 1` contributes nothing because `η_{K-1} ≡ 0` is constant
1550    /// in β). `out` must already be length `(K-1)·P`; it is overwritten.
1551    fn hessian_matvec_into_with_probs(
1552        &self,
1553        probs_full: ArrayView2<'_, f64>,
1554        v: &Array1<f64>,
1555        out: &mut Array1<f64>,
1556    ) -> Result<(), String> {
1557        let p = self.design.ncols();
1558        let m = self.active_classes();
1559        let n = self.weights.len();
1560        let total = m * p;
1561        if v.len() != total {
1562            return Err(format!(
1563                "MultinomialHessianWorkspace::hessian_matvec: v len {} != (K-1)·P = {total}",
1564                v.len()
1565            ));
1566        }
1567        if out.len() != total {
1568            return Err(format!(
1569                "MultinomialHessianWorkspace::hessian_matvec: out len {} != (K-1)·P = {total}",
1570                out.len()
1571            ));
1572        }
1573        out.fill(0.0);
1574        let design = self.design.view();
1575        let mut xv = vec![0.0_f64; m];
1576        for row in 0..n {
1577            let w = self.weights[row];
1578            if w == 0.0 {
1579                continue;
1580            }
1581            // step 1 + 2a: per-row directional η `(X v_b)_n` and the
1582            // probability-weighted scalar `s_n = Σ_b p_{n,b} (X v_b)_n`.
1583            let mut s = 0.0_f64;
1584            for b in 0..m {
1585                let mut acc = 0.0_f64;
1586                for j in 0..p {
1587                    acc += design[[row, j]] * v[b * p + j];
1588                }
1589                xv[b] = acc;
1590                s += probs_full[[row, b]] * acc;
1591            }
1592            // step 2b + 3: the row residual `r_{n,a}` scattered through Xᵀ.
1593            for a in 0..m {
1594                let r = w * probs_full[[row, a]] * (xv[a] - s);
1595                if r == 0.0 {
1596                    continue;
1597                }
1598                let base = a * p;
1599                for i in 0..p {
1600                    out[base + i] += design[[row, i]] * r;
1601                }
1602            }
1603        }
1604        Ok(())
1605    }
1606
1607    /// Matrix-free diagonal of the joint softmax Hessian. The only non-zero
1608    /// contribution to entry `(a·P+i, a·P+i)` is the block-diagonal Fisher
1609    /// term `Σ_n w_n p_{n,a}(1 − p_{n,a}) X_{n,i}²`; the off-diagonal
1610    /// `−p_a p_b` blocks never reach the diagonal. This is bit-identical to
1611    /// `assemble_joint_hessian(...).diag()` because (a) the per-row
1612    /// contribution `w · pa·(1−pa) · xi²` is built from the exact same
1613    /// scalar product chain `((w·pa·(1−pa)) · xi) · xi` that
1614    /// [`dense_block_xtwx`] flows through `scaled = wab · xi; acc += scaled · xj`
1615    /// at `i==j`, (b) the row sums are reduced through the same rayon
1616    /// `into_par_iter().fold(...).reduce(...)` partition tree, so the
1617    /// floating-point associativity of the parallel chunking matches the
1618    /// dense path bit-for-bit on identical input, and (c) the symmetrisation
1619    /// pass only averages strictly off-diagonal entries. Departing from
1620    /// (b) — e.g. a plain `for row in 0..n` serial loop here — would change
1621    /// the reduction order and break the bit-identical contract whenever
1622    /// rayon splits the dense path's row range into more than one chunk.
1623    fn hessian_diagonal_with_probs(&self, probs_full: ArrayView2<'_, f64>) -> Array1<f64> {
1624        let p = self.design.ncols();
1625        let m = self.active_classes();
1626        let n = self.weights.len();
1627        let dim = m * p;
1628        let design = self.design.view();
1629        gam_problem::outer_subsample::RowSet::All.par_reduce_fold(
1630            n,
1631            || Array1::<f64>::zeros(dim),
1632            |mut acc, row, _row_weight| {
1633                let w = self.weights[row];
1634                if w == 0.0 {
1635                    return acc;
1636                }
1637                for a in 0..m {
1638                    let pa = probs_full[[row, a]];
1639                    let waa = w * pa * (1.0 - pa);
1640                    if waa == 0.0 {
1641                        continue;
1642                    }
1643                    let base = a * p;
1644                    for i in 0..p {
1645                        let xi = design[[row, i]];
1646                        acc[base + i] += waa * xi * xi;
1647                    }
1648                }
1649                acc
1650            },
1651            |mut a, b| {
1652                a += &b;
1653                a
1654            },
1655        )
1656    }
1657
1658    /// Directional derivative of the per-row Fisher block along a
1659    /// coefficient direction `d_β` (length `(K-1)·P`). Returns the
1660    /// `(N, M, M)` jet `D_β H_row` whose `[n, a, b]` entry is
1661    /// `∂/∂t |_{t=0} { w_n · (δ_ab p_a(η + t d_η) − p_a(·) p_b(·)) }` with
1662    /// `d_η_n = X_n · d_β`.
1663    ///
1664    /// Using `∂p_a/∂η_c = p_a (δ_ac − p_c)` and writing `s_n :=
1665    /// Σ_c p_{n,c} · d_η_{n,c}` (the per-row probability-weighted direction
1666    /// scalar, restricted to active classes since the reference η is
1667    /// constant), the closed form is
1668    ///
1669    /// ```text
1670    ///   ∂p_{n,a}/∂t = p_{n,a} (d_η_{n,a} − s_n)
1671    /// ```
1672    ///
1673    /// and therefore
1674    ///
1675    /// ```text
1676    ///   D_β H_{n,a,b}[d_β] = w_n · ( δ_ab · ∂p_{n,a}/∂t
1677    ///                                 − ∂p_{n,a}/∂t · p_{n,b}
1678    ///                                 − p_{n,a} · ∂p_{n,b}/∂t )
1679    /// ```
1680    fn directional_fisher_jet(
1681        &self,
1682        eta: ArrayView2<'_, f64>,
1683        d_beta_flat: &Array1<f64>,
1684    ) -> Result<Array3<f64>, String> {
1685        let p = self.design.ncols();
1686        let m = self.active_classes();
1687        if d_beta_flat.len() != m * p {
1688            return Err(format!(
1689                "MultinomialFamily direction length {} != (K-1)·P = {}",
1690                d_beta_flat.len(),
1691                m * p
1692            ));
1693        }
1694        let probs_full = self.row_probabilities(eta);
1695        Ok(self.directional_fisher_jet_rows(probs_full.view(), d_beta_flat))
1696    }
1697
1698    /// Per-row `M×M` first-directional Fisher jet `Ĵ[row]` from frozen row
1699    /// probabilities (issue #932 matrix-free port).
1700    ///
1701    /// This is the *un-scattered* kernel of
1702    /// `assemble_directional_derivatives_from_probs`: it returns the
1703    /// per-row `M×M` block `Ĵ[row,a,b]` such that the dense directional
1704    /// derivative is exactly `B_d[(a,i),(b,j)] = Σ_row Ĵ[row,a,b]·X[row,i]·X[row,j]`.
1705    /// Its derivative arithmetic comes from [`softmax_fisher_perturbation`], the
1706    /// same normalized-softmax expression as every other live first/fourth-order
1707    /// consumer. Only the direction projection and X-factored scatter remain
1708    /// specialized; neither is calculus.
1709    fn directional_fisher_jet_rows(
1710        &self,
1711        probs_full: ArrayView2<'_, f64>,
1712        direction: &Array1<f64>,
1713    ) -> Array3<f64> {
1714        let n = self.weights.len();
1715        let p = self.design.ncols();
1716        let m = self.active_classes();
1717        let design = self.design.view();
1718        let mut out = Array3::<f64>::zeros((n, m, m));
1719        let mut d_eta = vec![0.0_f64; m];
1720        let mut normalized = vec![0.0; m];
1721        let out_flat = out
1722            .as_slice_mut()
1723            .expect("owned Fisher jet must be contiguous");
1724        for row in 0..n {
1725            let w = self.weights[row];
1726            if w == 0.0 {
1727                continue;
1728            }
1729            for a in 0..m {
1730                let base = a * p;
1731                let mut eta_dir = 0.0_f64;
1732                for i in 0..p {
1733                    eta_dir += design[[row, i]] * direction[base + i];
1734                }
1735                d_eta[a] = eta_dir;
1736            }
1737            let row_start = row * m * m;
1738            softmax_fisher_perturbation::<OneSeed<0>>(
1739                m,
1740                w,
1741                |a| probs_full[[row, a]],
1742                |a| d_eta[a],
1743                |_| 0.0,
1744                &mut normalized,
1745                &mut out_flat[row_start..row_start + m * m],
1746            );
1747        }
1748        out
1749    }
1750
1751    /// Per-row `M×M` second-directional Fisher jet from frozen row probabilities
1752    /// (issue #932 matrix-free port). The un-scattered kernel of
1753    /// `assemble_second_directional_derivatives_from_probs`, with
1754    /// per-row arithmetic byte-identical to the dense assembly so the
1755    /// matrix-free `Fᵀ B_{uv} F` projection matches the dense path up to row-sum
1756    /// associativity.
1757    fn second_directional_fisher_jet_rows(
1758        &self,
1759        probs_full: ArrayView2<'_, f64>,
1760        u: &Array1<f64>,
1761        v: &Array1<f64>,
1762    ) -> Array3<f64> {
1763        let n = self.weights.len();
1764        let p = self.design.ncols();
1765        let m = self.active_classes();
1766        let design = self.design.view();
1767        let mut out = Array3::<f64>::zeros((n, m, m));
1768        let mut d_eta_u = vec![0.0_f64; m];
1769        let mut d_eta_v = vec![0.0_f64; m];
1770        let mut normalized = vec![[0.0; 3]; m];
1771        let out_flat = out
1772            .as_slice_mut()
1773            .expect("owned Fisher jet must be contiguous");
1774        for row in 0..n {
1775            let w = self.weights[row];
1776            if w == 0.0 {
1777                continue;
1778            }
1779            for a in 0..m {
1780                let base = a * p;
1781                let mut eta_u = 0.0_f64;
1782                let mut eta_v = 0.0_f64;
1783                for i in 0..p {
1784                    let x = design[[row, i]];
1785                    eta_u += x * u[base + i];
1786                    eta_v += x * v[base + i];
1787                }
1788                d_eta_u[a] = eta_u;
1789                d_eta_v[a] = eta_v;
1790            }
1791            let row_start = row * m * m;
1792            softmax_fisher_perturbation::<TwoSeed<0>>(
1793                m,
1794                w,
1795                |a| probs_full[[row, a]],
1796                |a| d_eta_u[a],
1797                |a| d_eta_v[a],
1798                &mut normalized,
1799                &mut out_flat[row_start..row_start + m * m],
1800            );
1801        }
1802        out
1803    }
1804
1805    /// Build the matrix-free first-directional joint-Hessian operator (#932).
1806    /// Validates the direction length identically to the dense assembly and
1807    /// stores only the per-row `M×M` jet, so the operator's `Fᵀ B_d F`
1808    /// projection reproduces the dense `DenseMatrixHyperOperator` value to
1809    /// floating-point reassociation.
1810    fn directional_hyper_operator(
1811        &self,
1812        probs_full: ArrayView2<'_, f64>,
1813        direction: &Array1<f64>,
1814    ) -> Result<MultinomialDirectionalHyperOperator, String> {
1815        let dim = self.beta_flat_dim();
1816        if direction.len() != dim {
1817            return Err(format!(
1818                "MultinomialFamily matrix-free direction length {} != (K-1)·P = {dim}",
1819                direction.len()
1820            ));
1821        }
1822        Ok(MultinomialDirectionalHyperOperator {
1823            design: Arc::clone(&self.design),
1824            jet: self.directional_fisher_jet_rows(probs_full, direction),
1825            m: self.active_classes(),
1826            p: self.design.ncols(),
1827        })
1828    }
1829
1830    /// Build the matrix-free second-directional joint-Hessian operator (#932),
1831    /// the second-order sibling of [`Self::directional_hyper_operator`].
1832    fn second_directional_hyper_operator(
1833        &self,
1834        probs_full: ArrayView2<'_, f64>,
1835        u: &Array1<f64>,
1836        v: &Array1<f64>,
1837    ) -> Result<MultinomialDirectionalHyperOperator, String> {
1838        let dim = self.beta_flat_dim();
1839        if u.len() != dim || v.len() != dim {
1840            return Err(format!(
1841                "MultinomialFamily matrix-free second-directional pair lengths {} and {} != (K-1)·P = {dim}",
1842                u.len(),
1843                v.len()
1844            ));
1845        }
1846        Ok(MultinomialDirectionalHyperOperator {
1847            design: Arc::clone(&self.design),
1848            jet: self.second_directional_fisher_jet_rows(probs_full, u, v),
1849            m: self.active_classes(),
1850            p: self.design.ncols(),
1851        })
1852    }
1853
1854    /// Second directional derivative kernel `D²_β H[d_u, d_v]`. Built by
1855    /// differentiating the first-order kernel along a second direction.
1856    ///
1857    /// Let `d_η^u = X d_u`, `d_η^v = X d_v`, `s^u = Σ_c p_c d_η^u_c`,
1858    /// `s^v = Σ_c p_c d_η^v_c`. Then
1859    ///
1860    /// ```text
1861    ///   ∂p_a/∂t_u = p_a (d_η^u_a − s^u)
1862    ///   ∂²p_a/∂t_u∂t_v = (∂p_a/∂t_v)(d_η^u_a − s^u)
1863    ///                  + p_a ( − ∂s^u/∂t_v )
1864    ///   ∂s^u/∂t_v = Σ_c (∂p_c/∂t_v) d_η^u_c
1865    /// ```
1866    ///
1867    /// We then propagate the same δ/outer-product structure as in
1868    /// [`Self::directional_fisher_jet`].
1869    fn second_directional_fisher_jet(
1870        &self,
1871        eta: ArrayView2<'_, f64>,
1872        d_beta_u: &Array1<f64>,
1873        d_beta_v: &Array1<f64>,
1874    ) -> Result<Array3<f64>, String> {
1875        let p = self.design.ncols();
1876        let m = self.active_classes();
1877        let dim = m * p;
1878        if d_beta_u.len() != dim || d_beta_v.len() != dim {
1879            return Err(format!(
1880                "MultinomialFamily second-directional pair lengths {} and {} != (K-1)·P = {dim}",
1881                d_beta_u.len(),
1882                d_beta_v.len()
1883            ));
1884        }
1885        let probs_full = self.row_probabilities(eta);
1886        Ok(self.second_directional_fisher_jet_rows(probs_full.view(), d_beta_u, d_beta_v))
1887    }
1888
1889    /// Assemble the FULL set of canonical-axis joint-Hessian directional
1890    /// derivatives `{ Hdot[e_k] }` for every axis `k = a0·P + i0`, in a SINGLE
1891    /// shared softmax pass and one fused parallel row sweep — the exact value
1892    /// the Tier-B Jeffreys loop needs (it calls
1893    /// [`Self::exact_newton_joint_hessian_directional_derivative`] once per
1894    /// canonical axis at the SAME `β`).
1895    ///
1896    /// EXACTNESS. For the canonical axis `e_{(a0,i0)}` the design-projected
1897    /// η-direction is `d_η[row, b] = X[row, i0]·δ_{b,a0}` (only class `a0`'s
1898    /// channel moves, by `X[row, i0]`). Substituting into
1899    /// [`Self::directional_fisher_jet`] the per-row scalar collapses to
1900    /// `s = p_{a0}·X[row, i0]` and `∂p_c/∂t = p_c·X[row, i0]·(δ_{c,a0} − p_{a0})`,
1901    /// so the directional Fisher jet for this axis is `X[row, i0]·Ĵ_{a0}[row]`
1902    /// with `Ĵ_{a0}` the `M×M` per-row jet built from `dp̂_c = p_c (δ_{c,a0} −
1903    /// p_{a0})` (the `X[row, i0]` factor pulled out). Contracting through
1904    /// [`dense_block_xtwx`]'s `Σ_row J[c,d] X[row,i] X[row,j]` then gives
1905    ///
1906    /// ```text
1907    ///   Hdot[e_{(a0,i0)}][(c,i),(d,j)] = Σ_row Ĵ_{a0}[row,c,d] · X[row,i0] X[row,i] X[row,j].
1908    /// ```
1909    ///
1910    /// This is BIT-FAITHFUL to the per-axis `directional_fisher_jet` →
1911    /// `dense_block_xtwx` path it replaces up to the associativity of the row
1912    /// sum, computed once for all `p` axes instead of `p` times with `p`
1913    /// redundant softmax passes and `p` generic `(M·P)²` Gram allocations
1914    /// (#715/#722/#753 Firth grind). The row sweep is fanned across the rayon
1915    /// pool with per-thread accumulators reduced by addition, mirroring
1916    /// `dense_block_xtwx`.
1917    fn assemble_all_axis_directional_derivatives(
1918        &self,
1919        eta: ArrayView2<'_, f64>,
1920    ) -> Vec<Array2<f64>> {
1921        use rayon::iter::{IntoParallelIterator, ParallelIterator};
1922        let n = self.weights.len();
1923        let p = self.design.ncols();
1924        let m = self.active_classes();
1925        let dim = m * p;
1926        let n_axes = m * p;
1927        let probs_full = self.row_probabilities(eta);
1928        let design = self.design.view();
1929        // #1082: parallelise over OUTPUT AXES, not rows. The earlier row-fold
1930        // allocated and zeroed a single flat `n_axes·dim·dim` accumulator PER
1931        // rayon worker (e.g. ~370k f64 ≈ 3 MB each at the penguin K=3, k=10 fit)
1932        // every call, then summed them all in a `reduce` — and this function is
1933        // the per-inner-cycle hot path of the near-separable Jeffreys/Firth solve
1934        // (gam#1082), so that `memset` + reduce dominated the wall clock. Each
1935        // axis `(a0,i0)` writes only its own `dim·dim` block and is independent of
1936        // every other axis, so mapping over axes drops the giant per-worker buffer
1937        // (each task owns one `dim·dim` block ≈ 40 kB), removes the reduce, and
1938        // load-balances across the `n_axes = m·p` outputs. The per-row arithmetic
1939        // is unchanged; only the summation order differs (each block now sums rows
1940        // in index order), which the parity tests admit to 1e-10.
1941        (0..n_axes)
1942            .into_par_iter()
1943            .map(|axis| {
1944                let a0 = axis / p;
1945                let i0 = axis % p;
1946                let mut mat = vec![0.0_f64; dim * dim];
1947                let mut normalized = vec![0.0; m];
1948                let mut jhat = vec![0.0_f64; m * m];
1949                for row in 0..n {
1950                    let w = self.weights[row];
1951                    if w == 0.0 {
1952                        continue;
1953                    }
1954                    let xi0 = design[[row, i0]];
1955                    if xi0 == 0.0 {
1956                        continue;
1957                    }
1958                    softmax_fisher_perturbation::<OneSeed<0>>(
1959                        m,
1960                        w,
1961                        |c| probs_full[[row, c]],
1962                        |c| if c == a0 { 1.0 } else { 0.0 },
1963                        |_| 0.0,
1964                        &mut normalized,
1965                        &mut jhat,
1966                    );
1967                    // Scatter `X[row,i0] · Ĵ_{a0}[c,d] · X[row,i] X[row,j]` into
1968                    // this axis's `(dim,dim)` block (output-major: block `(c,d)`
1969                    // at rows `c·P..`, cols `d·P..`).
1970                    for c in 0..m {
1971                        let row_c = c * p;
1972                        for d in 0..m {
1973                            let jcd = jhat[c * m + d];
1974                            if jcd == 0.0 {
1975                                continue;
1976                            }
1977                            let wcd = xi0 * jcd;
1978                            let col_d = d * p;
1979                            for i in 0..p {
1980                                let xi = design[[row, i]];
1981                                if xi == 0.0 {
1982                                    continue;
1983                                }
1984                                let scaled = wcd * xi;
1985                                let out_row = (row_c + i) * dim;
1986                                for j in 0..p {
1987                                    mat[out_row + col_d + j] += scaled * design[[row, j]];
1988                                }
1989                            }
1990                        }
1991                    }
1992                }
1993                let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
1994                    .expect("axis derivative buffer is dim·dim");
1995                // Symmetrise to cancel accumulator drift (matching
1996                // `dense_block_xtwx`'s final pass so the result is identical to
1997                // the per-axis route).
1998                for i in 0..dim {
1999                    for j in (i + 1)..dim {
2000                        let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
2001                        mat[[i, j]] = avg;
2002                        mat[[j, i]] = avg;
2003                    }
2004                }
2005                mat
2006            })
2007            .collect()
2008    }
2009
2010    /// Assemble the FULL set of second-directional joint-Hessian derivatives
2011    /// `{ H²dot[δ, e_a] }` for a FIXED first direction `δ = d_beta_u` and every
2012    /// canonical second axis `a = a0·P + i0`, in a SINGLE shared softmax pass and
2013    /// one fused parallel row sweep — the value the Tier-B Jeffreys drift needs
2014    /// (it requests every canonical second axis at the same `β` and `δ`).
2015    ///
2016    /// EXACTNESS / FACTORISATION. For the canonical second axis `e_{(a0,i0)}` the
2017    /// design-projected v-direction is `d_η_v[row,b] = X[row,i0]·δ_{b,a0}`, so the
2018    /// per-row second-directional Fisher jet from
2019    /// [`Self::second_directional_fisher_jet`] factors as
2020    /// `X[row,i0]·Ĵ²_{a0,δ}[row]`, where the `X[row,i0]`-free per-row `M×M` jet
2021    /// `Ĵ²_{a0,δ}` is built from the SAME closed form with the `X[row,i0]` factor
2022    /// pulled out of the v-side quantities:
2023    /// ```text
2024    ///   s_u       = Σ_c p_c d_η^u_c                           (shared, δ-only)
2025    ///   dp_u[c]   = p_c (d_η^u_c − s_u)                        (shared, δ-only)
2026    ///   dp̂_v[c]   = p_c (δ_{c,a0} − p_{a0})                    (a0-only, X-free)
2027    ///   dŝ_u_dv   = Σ_c dp̂_v[c] d_η^u_c                        (a0,δ)
2028    ///   ddp̂[c]    = dp̂_v[c] (d_η^u_c − s_u) − p_c · dŝ_u_dv     (a0,δ)
2029    ///   Ĵ²[a,a]   = w ( ddp̂[a](1 − 2p_a) − 2 dp_u[a] dp̂_v[a] )
2030    ///   Ĵ²[a,b]   = −w ( ddp̂[a] p_b + dp_u[a] dp̂_v[b] + dp̂_v[a] dp_u[b] + p_a ddp̂[b] )
2031    /// ```
2032    /// Contracting through [`dense_block_xtwx`]'s `Σ_row J[c,d] X[row,i] X[row,j]`
2033    /// then gives
2034    /// ```text
2035    ///   H²dot[δ, e_{(a0,i0)}][(c,i),(d,j)] = Σ_row Ĵ²_{a0,δ}[row,c,d] · X[row,i0] X[row,i] X[row,j].
2036    /// ```
2037    /// This is BIT-FAITHFUL to the per-axis `second_directional_fisher_jet` →
2038    /// `dense_block_xtwx` path the trait default runs, up to row-sum
2039    /// associativity, computed once for all `p = (M·P)` axes instead of `p` times
2040    /// with `p` redundant softmax passes and `p` generic `(M·P)²` Gram
2041    /// allocations — the #1082 / #979 outer-Jeffreys-drift Gram rebuild the
2042    /// profile pins on `dense_block_xtwx` (≈half the smooth-by-factor wall-clock).
2043    fn assemble_all_axis_second_directional_derivatives(
2044        &self,
2045        eta: ArrayView2<'_, f64>,
2046        d_beta_u: &Array1<f64>,
2047    ) -> Result<Vec<Array2<f64>>, String> {
2048        use rayon::iter::{IntoParallelIterator, ParallelIterator};
2049        let n = self.weights.len();
2050        let p = self.design.ncols();
2051        let m = self.active_classes();
2052        let dim = m * p;
2053        let n_axes = m * p;
2054        let probs_full = self.row_probabilities(eta);
2055        let d_eta_u = self.d_eta_from_d_beta(d_beta_u)?;
2056        let design = self.design.view();
2057        // #1082: parallelise over OUTPUT AXES instead of rows, dropping the
2058        // `n_axes·dim·dim` per-worker accumulator + `reduce` (see the matching
2059        // note on `assemble_all_axis_directional_derivatives`). Each axis owns
2060        // one `dim·dim` block and is independent. The per-row arithmetic is
2061        // unchanged; only the row-summation order differs (admitted to 1e-10 by
2062        // the batched/per-axis parity tests).
2063        let out: Vec<Array2<f64>> = (0..n_axes)
2064            .into_par_iter()
2065            .map(|axis| {
2066                let a0 = axis / p;
2067                let i0 = axis % p;
2068                let mut mat = vec![0.0_f64; dim * dim];
2069                let mut normalized = vec![[0.0; 3]; m];
2070                let mut jhat = vec![0.0_f64; m * m];
2071                for row in 0..n {
2072                    let w = self.weights[row];
2073                    if w == 0.0 {
2074                        continue;
2075                    }
2076                    let xi0 = design[[row, i0]];
2077                    if xi0 == 0.0 {
2078                        continue;
2079                    }
2080                    softmax_fisher_perturbation::<TwoSeed<0>>(
2081                        m,
2082                        w,
2083                        |c| probs_full[[row, c]],
2084                        |c| d_eta_u[[row, c]],
2085                        |c| if c == a0 { 1.0 } else { 0.0 },
2086                        &mut normalized,
2087                        &mut jhat,
2088                    );
2089                    // Scatter `X[row,i0] · Ĵ²_{a0}[c,d] · X[row,i] X[row,j]` into
2090                    // this axis's `(dim,dim)` block (output-major).
2091                    for c in 0..m {
2092                        let row_c = c * p;
2093                        for d in 0..m {
2094                            let jcd = jhat[c * m + d];
2095                            if jcd == 0.0 {
2096                                continue;
2097                            }
2098                            let wcd = xi0 * jcd;
2099                            let col_d = d * p;
2100                            for i in 0..p {
2101                                let xi = design[[row, i]];
2102                                if xi == 0.0 {
2103                                    continue;
2104                                }
2105                                let scaled = wcd * xi;
2106                                let out_row = (row_c + i) * dim;
2107                                for j in 0..p {
2108                                    mat[out_row + col_d + j] += scaled * design[[row, j]];
2109                                }
2110                            }
2111                        }
2112                    }
2113                }
2114                let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
2115                    .expect("axis second-derivative buffer is dim·dim");
2116                for i in 0..dim {
2117                    for j in (i + 1)..dim {
2118                        let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
2119                        mat[[i, j]] = avg;
2120                        mat[[j, i]] = avg;
2121                    }
2122                }
2123                mat
2124            })
2125            .collect();
2126        Ok(out)
2127    }
2128
2129    /// Index of the single canonical axis `k` if `d_beta_flat` is the unit
2130    /// vector `e_k` (the Tier-B Jeffreys loop's request shape), else `None`.
2131    fn canonical_axis_index(&self, d_beta_flat: &Array1<f64>) -> Option<usize> {
2132        let mut axis: Option<usize> = None;
2133        for (k, &v) in d_beta_flat.iter().enumerate() {
2134            if v == 0.0 {
2135                continue;
2136            }
2137            if v != 1.0 || axis.is_some() {
2138                return None;
2139            }
2140            axis = Some(k);
2141        }
2142        axis
2143    }
2144
2145    /// Joint-Hessian directional derivative along a single canonical axis `e_k`,
2146    /// served from the shared per-`β` memo. The first axis requested at a fresh
2147    /// `β` assembles the WHOLE set in one softmax pass
2148    /// ([`Self::assemble_all_axis_directional_derivatives`]); every subsequent
2149    /// axis of that Jeffreys loop is a cache read — turning the term's `O(p)`
2150    /// redundant softmax/Gram rebuilds into a single shared pass (#715/#722).
2151    fn cached_axis_directional_derivative(
2152        &self,
2153        eta: ArrayView2<'_, f64>,
2154        axis: usize,
2155    ) -> Array2<f64> {
2156        let key = EtaFingerprint::of(eta);
2157        {
2158            let guard = self
2159                .axis_derivative_cache
2160                .lock()
2161                .expect("axis derivative cache mutex poisoned");
2162            if let Some(cache) = guard.as_ref()
2163                && cache.eta_key == key
2164            {
2165                return cache.derivatives[axis].clone();
2166            }
2167        }
2168        // Cache miss (fresh β): assemble the full axis set ONCE, store it, return
2169        // the requested axis. Assembly happens outside the lock so concurrent
2170        // requesters at the same β never block on each other's full sweep — a
2171        // redundant assemble is wasteful but never wrong (pure function of β).
2172        let derivatives = self.assemble_all_axis_directional_derivatives(eta);
2173        let result = derivatives[axis].clone();
2174        let mut guard = self
2175            .axis_derivative_cache
2176            .lock()
2177            .expect("axis derivative cache mutex poisoned");
2178        *guard = Some(AxisDerivativeCache {
2179            eta_key: key,
2180            derivatives,
2181        });
2182        result
2183    }
2184}
2185
2186impl CustomFamily for MultinomialFamily {
2187    fn joint_jeffreys_term_required(&self) -> bool {
2188        self.use_joint_jeffreys_term
2189    }
2190
2191    fn joint_penalty_specs(&self) -> Result<Vec<gam_problem::JointPenaltySpec>, String> {
2192        // The smoothing carrier is the permutation-equivariant per-class
2193        // centered penalty family: K per-term λ_{t,c} on the CENTERED class
2194        // functions γ_c (see `equivariant_class_penalty_specs`). This restores
2195        // the #1587 reference invariance the per-block ALR carrier broke
2196        // (relabeling the arbitrary baseline changed fitted probabilities)
2197        // while keeping the heterogeneous per-class smoothness #1855 requires
2198        // — per-CLASS λ on gauge-free functions, not per-contrast λ in the
2199        // reference-anchored frame. The per-class blocks attach NO smooth
2200        // penalty (see `build_block_specs`); double-carrying both would
2201        // penalize (I + Σ_c C_cᵀC_c) ⊗ S_t.
2202        self.equivariant_class_penalty_specs()
2203    }
2204
2205    fn exact_newton_joint_hessian_beta_dependent(&self) -> bool {
2206        // H = X^T W(β) X with W depending on softmax probabilities of β.
2207        true
2208    }
2209
2210    fn has_explicit_joint_hessian(&self) -> bool {
2211        true
2212    }
2213
2214    fn requires_joint_outer_hyper_path(&self) -> bool {
2215        // Off-diagonal block coupling in H ⇒ blockwise diagonal surrogate
2216        // is mathematically invalid; force the joint exact path.
2217        true
2218    }
2219
2220    fn levenberg_on_ill_conditioning(&self) -> bool {
2221        // Engage the self-vanishing Levenberg–Marquardt damping on a FULL-RANK
2222        // but ILL-CONDITIONED penalized joint Hessian, not only on a
2223        // rank-deficient one.
2224        //
2225        // The penalized multinomial joint information is `H = Jᵀ W(β) J + S_λ`
2226        // with the softmax Fisher weight `W = diag(p) − p pᵀ`, which collapses
2227        // toward zero as fitted probabilities saturate near the simplex boundary
2228        // (the near-separating regime of small, well-fit categorical data — e.g.
2229        // the penguins `species ~ s(bill) + s(flipper) + body_mass` fit). There
2230        // `H` stays full rank but becomes ILL-CONDITIONED: range-space
2231        // curvature directions sit just above the rank cutoff. Undamped, the
2232        // range-restricted joint-Newton step takes an
2233        // enormous `component/λ` proposal on those near-singular modes, the trust
2234        // region clips it every cycle, and the stationarity residual along that
2235        // mode never settles — the inner solve oscillates and never certifies a
2236        // KKT point, so the outer REML startup seeds are all rejected (#715
2237        // real-data arm: "canonical-gauge null direction rejects all REML
2238        // seeds"; the macOS verdict's `phantom_multiplier_with_well_conditioned_H`
2239        // is the same near-singular-but-full-rank certificate failure).
2240        //
2241        // Because `μ ∝ ‖∇L − Sβ‖∞ → 0` at the fixed point, the damping only
2242        // shapes the trajectory (oscillation → bounded descent); the converged β,
2243        // the selected λ, and the KKT certificate are unchanged, so the
2244        // truth-recovery / match-or-beat bars are evaluated against the same
2245        // optimum and are never weakened.
2246        true
2247    }
2248
2249    fn inner_coefficient_hessian_hvp_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2250        self.specs_match_workspace_shape(specs)
2251    }
2252
2253    fn inner_joint_workspace_gradient_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2254        self.specs_match_workspace_shape(specs)
2255    }
2256
2257    fn inner_joint_workspace_log_likelihood_available(&self, specs: &[ParameterBlockSpec]) -> bool {
2258        self.specs_match_workspace_shape(specs)
2259    }
2260
2261    fn coefficient_hessian_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
2262        // Every row contributes a rank-M outer product across the joint
2263        // (Σ p_b)² = (M · P)² space — the canonical joint-coupled cost.
2264        crate::custom_family::joint_coupled_coefficient_hessian_cost(
2265            self.weights.len() as u64,
2266            specs,
2267        )
2268    }
2269
2270    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
2271        let eta = self.collect_eta_matrix(block_states)?;
2272        let (log_lik, fisher, grad_eta_logl) = self.evaluate_row_kernels(eta.view())?;
2273        let working_sets = self.assemble_block_diagonal_working_sets(&fisher, &grad_eta_logl)?;
2274        Ok(FamilyEvaluation {
2275            log_likelihood: log_lik,
2276            blockworking_sets: working_sets,
2277        })
2278    }
2279
2280    fn log_likelihood_only(&self, block_states: &[ParameterBlockState]) -> Result<f64, String> {
2281        let eta = self.collect_eta_matrix(block_states)?;
2282        self.likelihood
2283            .log_lik(eta.view(), self.y_one_hot.view())
2284            .map_err(|error| error.to_string())
2285    }
2286
2287    fn exact_newton_joint_hessian(
2288        &self,
2289        block_states: &[ParameterBlockState],
2290    ) -> Result<Option<Array2<f64>>, String> {
2291        let eta = self.collect_eta_matrix(block_states)?;
2292        let (_, fisher, _) = self.evaluate_row_kernels(eta.view())?;
2293        let hessian = self.assemble_joint_hessian(&fisher)?;
2294        Ok(Some(hessian))
2295    }
2296
2297    fn exact_newton_joint_gradient_evaluation(
2298        &self,
2299        block_states: &[ParameterBlockState],
2300        _: &[ParameterBlockSpec],
2301    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
2302        let eta = self.collect_eta_matrix(block_states)?;
2303        let (log_lik, grad_eta_logl) = self
2304            .likelihood
2305            .value_gradient(eta.view(), self.y_one_hot.view())
2306            .map_err(|error| error.to_string())?;
2307        let gradient = self.assemble_joint_gradient(&grad_eta_logl);
2308        Ok(Some(ExactNewtonJointGradientEvaluation {
2309            log_likelihood: log_lik,
2310            gradient,
2311        }))
2312    }
2313
2314    fn exact_newton_joint_hessian_workspace(
2315        &self,
2316        block_states: &[ParameterBlockState],
2317        _: &[ParameterBlockSpec],
2318    ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
2319        // Freeze the per-row softmax probabilities once at construction: the
2320        // Fisher block H_{n,a,b} = w_n (δ_ab p_a − p_a p_b) is constant in the
2321        // matvec direction v, so every PCG H·v contraction reuses these probs
2322        // rather than re-running the softmax (matrix-free, O(N·K·P) per matvec
2323        // with no dense (M·P)² assembly — issue #347).
2324        let eta = self.collect_eta_matrix(block_states)?;
2325        let probs = self.row_probabilities(eta.view());
2326        Ok(Some(Arc::new(MultinomialHessianWorkspace {
2327            family: self.clone(),
2328            block_states: block_states.to_vec(),
2329            eta,
2330            probs,
2331        })))
2332    }
2333
2334    fn exact_newton_joint_hessian_directional_derivative(
2335        &self,
2336        block_states: &[ParameterBlockState],
2337        d_beta_flat: &Array1<f64>,
2338    ) -> Result<Option<Array2<f64>>, String> {
2339        let eta = self.collect_eta_matrix(block_states)?;
2340        if d_beta_flat.len() != self.beta_flat_dim() {
2341            return Err(format!(
2342                "MultinomialFamily direction length {} != (K-1)·P = {}",
2343                d_beta_flat.len(),
2344                self.beta_flat_dim()
2345            ));
2346        }
2347        // FAST PATH (the Tier-B Jeffreys/Firth loop): the term requests every
2348        // canonical axis `e_k` at the same β. Serve from the shared per-β memo so
2349        // the full set is assembled in ONE softmax pass and each axis is a cache
2350        // read, instead of `p` independent softmax + `dense_block_xtwx` rebuilds
2351        // (#715/#722/#753). The cached value is bit-faithful to the generic path
2352        // up to row-sum associativity.
2353        if let Some(axis) = self.canonical_axis_index(d_beta_flat) {
2354            return Ok(Some(
2355                self.cached_axis_directional_derivative(eta.view(), axis),
2356            ));
2357        }
2358        // General direction (e.g. the outer mode-response drift `Hdot[δ]`): the
2359        // exact per-direction jet → dense contraction.
2360        let dh_fisher = self.directional_fisher_jet(eta.view(), d_beta_flat)?;
2361        let dh = dense_block_xtwx(self.design.view(), dh_fisher.view(), None)
2362            .map_err(|e| format!("MultinomialFamily directional H assembly: {e}"))?;
2363        Ok(Some(dh))
2364    }
2365
2366    fn joint_jeffreys_information_directional_derivative_all_axes_with_specs(
2367        &self,
2368        block_states: &[ParameterBlockState],
2369        specs: &[ParameterBlockSpec],
2370    ) -> Result<Option<Vec<Array2<f64>>>, String> {
2371        // BATCHED all-axes fast path for the Tier-B Jeffreys/Firth loop
2372        // (#979). The generic trait default queries `Hdot[e_a]` `p = (K−1)·P`
2373        // separate times through the per-axis hook; each call takes the
2374        // axis-derivative cache Mutex and CLONES a full `dim×dim` matrix out
2375        // of the memo, and the default sweep runs SERIALLY. Multinomial
2376        // already assembles the WHOLE axis set in ONE row-parallel softmax pass
2377        // (`assemble_all_axis_directional_derivatives`, fanned over the n rows
2378        // with a per-thread fold/reduce). Wire that directly here: a single
2379        // parallel build, returned by move with no per-axis Mutex traffic or
2380        // dim×dim clones. Bit-identical to the per-axis route by construction —
2381        // it is the very function `cached_axis_directional_derivative` fills its
2382        // memo from, so each returned axis matrix equals the cached clone the
2383        // serial loop would have produced. The β-fixed `η` comes from
2384        // `block_states` exactly as the per-axis
2385        // `exact_newton_joint_hessian_directional_derivative` does.
2386        let eta = self.collect_eta_matrix(block_states)?;
2387        let axes = self.assemble_all_axis_directional_derivatives(eta.view());
2388        // The caller indexes the returned Vec by canonical axis a ∈ 0..p, where
2389        // p = Σ spec.design.ncols() is the joint coefficient dimension across the
2390        // coupled softmax blocks. Report (do NOT fail) if the batched assembly's
2391        // axis count disagrees with the spec-derived p — a mismatch is a
2392        // block-structure bug worth surfacing, but a non-fatal warning so a
2393        // working fit is never broken on this dimension invariant.
2394        let p: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
2395        if axes.len() != p {
2396            log::warn!(
2397                "multinomial all-axes Jeffreys derivative produced {} axes but the block specs \
2398                 describe p={p} joint coefficients (canonical-axis count mismatch)",
2399                axes.len()
2400            );
2401        }
2402        Ok(Some(axes))
2403    }
2404
2405    fn joint_jeffreys_information_second_directional_all_axes_with_specs(
2406        &self,
2407        block_states: &[ParameterBlockState],
2408        specs: &[ParameterBlockSpec],
2409        d_beta_u_flat: &Array1<f64>,
2410    ) -> Result<Option<Vec<Array2<f64>>>, String> {
2411        // BATCHED all-axes SECOND-directional fast path for the Tier-B Jeffreys
2412        // outer drift (#1082 / #979). The generic trait default queries
2413        // `H²dot[δ, e_a]` `p = (M·P)` separate times, each rebuilding the full
2414        // `O(n·M²·P²)` coupled Gram through `dense_block_xtwx` — the profile-pinned
2415        // outer hot spot (≈half the smooth-by-factor wall-clock; the drift batch
2416        // calls this once per mode-response direction). Multinomial assembles the
2417        // WHOLE second-axis set in ONE row-parallel softmax pass via the
2418        // X[row,i0]-factored per-row second jet (see
2419        // `assemble_all_axis_second_directional_derivatives`), bit-faithful to the
2420        // per-axis `second_directional_fisher_jet → dense_block_xtwx` route up to
2421        // row-sum associativity, for a single Gram-assembly cost instead of `p`.
2422        let eta = self.collect_eta_matrix(block_states)?;
2423        let axes =
2424            self.assemble_all_axis_second_directional_derivatives(eta.view(), d_beta_u_flat)?;
2425        // Same canonical-axis contract as the first-directional batch: the caller
2426        // indexes by a ∈ 0..p with p = Σ spec.design.ncols(). Report a mismatch
2427        // non-fatally (a block-structure bug worth surfacing) rather than failing
2428        // a working fit on this dimension invariant.
2429        let p: usize = specs.iter().map(|spec| spec.design.ncols()).sum();
2430        if axes.len() != p {
2431            log::warn!(
2432                "multinomial all-axes second Jeffreys derivative produced {} axes but the block \
2433                 specs describe p={p} joint coefficients (canonical-axis count mismatch)",
2434                axes.len()
2435            );
2436        }
2437        Ok(Some(axes))
2438    }
2439
2440    fn exact_newton_joint_hessiansecond_directional_derivative(
2441        &self,
2442        block_states: &[ParameterBlockState],
2443        d_beta_u_flat: &Array1<f64>,
2444        d_beta_v_flat: &Array1<f64>,
2445    ) -> Result<Option<Array2<f64>>, String> {
2446        let eta = self.collect_eta_matrix(block_states)?;
2447        let d2h_fisher =
2448            self.second_directional_fisher_jet(eta.view(), d_beta_u_flat, d_beta_v_flat)?;
2449        let d2h = dense_block_xtwx(self.design.view(), d2h_fisher.view(), None)
2450            .map_err(|e| format!("MultinomialFamily second directional H assembly: {e}"))?;
2451        Ok(Some(d2h))
2452    }
2453}
2454
2455/// Workspace holding a frozen `(family, β)` snapshot from which the outer
2456/// exact-Newton driver pulls dense, matvec, and directional-derivative
2457/// views of the joint penalized Hessian.
2458///
2459/// Equivalent in spirit to `LatentHessianWorkspace` in
2460/// [`crate::survival::latent`]; the multinomial case keeps a
2461/// single workspace type because the family has no per-block
2462/// configuration to specialise on.
2463struct MultinomialHessianWorkspace {
2464    family: MultinomialFamily,
2465    block_states: Vec<ParameterBlockState>,
2466    /// Frozen active logits. Values cannot be reconstructed from probabilities
2467    /// after tail underflow, so the canonical row expression retains them for
2468    /// exact value/gradient workspace queries.
2469    eta: Array2<f64>,
2470    /// Per-row softmax probabilities `(N, K)` (including the reference column
2471    /// at index `K − 1`), frozen at the construction `β`. The Fisher block is
2472    /// a function of these alone, so the matrix-free `H·v` contraction reuses
2473    /// them across every PCG iteration (issue #347).
2474    probs: Array2<f64>,
2475}
2476
2477impl ExactNewtonJointHessianWorkspace for MultinomialHessianWorkspace {
2478    fn warm_up_outer_caches_for_mode(
2479        &self,
2480        eval_mode: gam_problem::EvalMode,
2481    ) -> Result<(), String> {
2482        match eval_mode {
2483            gam_problem::EvalMode::ValueOnly
2484            | gam_problem::EvalMode::ValueAndGradient
2485            | gam_problem::EvalMode::ValueGradientHessian => Ok(()),
2486        }
2487    }
2488
2489    fn hessian_dense(&self) -> Result<Option<Array2<f64>>, String> {
2490        self.family.exact_newton_joint_hessian(&self.block_states)
2491    }
2492
2493    fn hessian_source_preference(&self) -> JointHessianSourcePreference {
2494        // The dense joint Hessian is `(K−1)P × (K−1)P` and the per-row Fisher
2495        // block is rank-M with a closed-form `H·v` contraction, so the
2496        // operator/PCG source is strictly cheaper than assembling and
2497        // factorizing the dense matrix every inner cycle. Prefer it so the
2498        // workspace-routed inner Newton never materializes the dense Hessian
2499        // (#714 / #722 inner cost).
2500        JointHessianSourcePreference::Operator
2501    }
2502
2503    fn joint_log_likelihood_evaluation(&self) -> Result<Option<f64>, String> {
2504        let (log_lik, _) = self
2505            .family
2506            .joint_loglik_and_gradient_from_probs(self.eta.view(), self.probs.view())?;
2507        Ok(Some(log_lik))
2508    }
2509
2510    fn joint_gradient_evaluation(
2511        &self,
2512    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
2513        let (log_likelihood, gradient) = self
2514            .family
2515            .joint_loglik_and_gradient_from_probs(self.eta.view(), self.probs.view())?;
2516        Ok(Some(ExactNewtonJointGradientEvaluation {
2517            log_likelihood,
2518            gradient,
2519        }))
2520    }
2521
2522    fn hessian_matvec_available(&self) -> bool {
2523        true
2524    }
2525
2526    fn hessian_matvec(&self, v: &Array1<f64>) -> Result<Option<Array1<f64>>, String> {
2527        let mut out = Array1::<f64>::zeros(self.family.beta_flat_dim());
2528        self.family
2529            .hessian_matvec_into_with_probs(self.probs.view(), v, &mut out)?;
2530        Ok(Some(out))
2531    }
2532
2533    fn hessian_matvec_into(&self, v: &Array1<f64>, out: &mut Array1<f64>) -> Result<bool, String> {
2534        self.family
2535            .hessian_matvec_into_with_probs(self.probs.view(), v, out)?;
2536        Ok(true)
2537    }
2538
2539    fn hessian_diagonal(&self) -> Result<Option<Array1<f64>>, String> {
2540        Ok(Some(
2541            self.family.hessian_diagonal_with_probs(self.probs.view()),
2542        ))
2543    }
2544
2545    fn directional_derivative(
2546        &self,
2547        d_beta_flat: &Array1<f64>,
2548    ) -> Result<Option<Array2<f64>>, String> {
2549        self.family
2550            .exact_newton_joint_hessian_directional_derivative(&self.block_states, d_beta_flat)
2551    }
2552
2553    fn directional_derivative_operators(
2554        &self,
2555        d_beta_flats: &[Array1<f64>],
2556    ) -> Result<Vec<Option<Arc<dyn HyperOperator>>>, String> {
2557        // #932 cutover: the matrix-free `MultinomialDirectionalHyperOperator` is
2558        // the sole production path. It stores only the per-row `M×M` Fisher jet
2559        // and contracts against the design on the fly, never materializing the
2560        // dense `(M·P)×(M·P)` block matrix nor paying the generic dense
2561        // projection — the multinomial analogue of the primary-GLM matrix-free
2562        // `trace_projected_factor_all_axes_with_xf`.
2563        let probs = self.probs.view();
2564        d_beta_flats
2565            .iter()
2566            .map(|direction| {
2567                self.family
2568                    .directional_hyper_operator(probs, direction)
2569                    .map(|op| Some(Arc::new(op) as Arc<dyn HyperOperator>))
2570            })
2571            .collect()
2572    }
2573
2574    fn second_directional_derivative(
2575        &self,
2576        d_beta_u: &Array1<f64>,
2577        d_beta_v: &Array1<f64>,
2578    ) -> Result<Option<Array2<f64>>, String> {
2579        self.family
2580            .exact_newton_joint_hessiansecond_directional_derivative(
2581                &self.block_states,
2582                d_beta_u,
2583                d_beta_v,
2584            )
2585    }
2586
2587    fn second_directional_derivative_operators(
2588        &self,
2589        d_beta_pairs: &[(Array1<f64>, Array1<f64>)],
2590    ) -> Result<Vec<Option<Arc<dyn HyperOperator>>>, String> {
2591        // #932 cutover: matrix-free second-directional operator is the sole
2592        // production path (see `directional_derivative_operators`).
2593        let probs = self.probs.view();
2594        d_beta_pairs
2595            .iter()
2596            .map(|(u, v)| {
2597                self.family
2598                    .second_directional_hyper_operator(probs, u, v)
2599                    .map(|op| Some(Arc::new(op) as Arc<dyn HyperOperator>))
2600            })
2601            .collect()
2602    }
2603}
2604
2605/// Matrix-free directional / second-directional joint-Hessian operator for the
2606/// multinomial-logit family (issue #932) — the sole production path for the
2607/// outer-Hessian directional terms (the dense `DenseMatrixHyperOperator`
2608/// assembly was cut over to this operator).
2609///
2610/// The former dense path (`assemble_directional_derivatives_from_probs` →
2611/// `DenseMatrixHyperOperator`, now retained only as the parity oracle's
2612/// reference) materializes the full `(M·P)×(M·P)` block matrix
2613///
2614/// ```text
2615///   B_d[(a,i),(b,j)] = Σ_row Ĵ[row,a,b] · X[row,i] · X[row,j]
2616/// ```
2617///
2618/// (an `O(N·M²·P²)` assembly) and then runs the generic dense projection
2619/// `Fᵀ B_d F` (an `O((M·P)²·rank)` GEMM pair). This operator instead stores only
2620/// the cheap per-row `M×M` Fisher jet `Ĵ` (`O(N·M²)`) and contracts against the
2621/// design on the fly — the multinomial analogue of the primary-GLM matrix-free
2622/// `ImplicitHyperOperator::trace_projected_factor_all_axes_with_xf`: precompute
2623/// `X·F` once per projection, contract per row over the `M×M` jet, and never
2624/// build the `(M·P)²` matrix or pay the dense projection. The projected matrix is
2625///
2626/// ```text
2627///   (Fᵀ B_d F)[k,l] = Σ_row Σ_{a,b} Ĵ[row,a,b] · g[row,a,k] · g[row,b,l],
2628///   where  g[row,a,k] = Σ_i X[row,i] · F[a·P+i, k].
2629/// ```
2630///
2631/// `is_implicit()` is `false` so the outer kernel treats this exactly like the
2632/// dense operator it replaces — the exact projected/trace path, never the
2633/// stochastic Hutch++ estimator (which would violate the ≤1e-10 contract).
2634struct MultinomialDirectionalHyperOperator {
2635    /// Shared `N×P` design (zero-copy clone of the family's `Arc`).
2636    design: Arc<Array2<f64>>,
2637    /// Per-row `M×M` Fisher-derivative jet `Ĵ[row]` (symmetric in `a,b`).
2638    jet: Array3<f64>,
2639    /// Active class count `M = K−1`.
2640    m: usize,
2641    /// Per-class feature count `P`.
2642    p: usize,
2643}
2644
2645impl HyperOperator for MultinomialDirectionalHyperOperator {
2646    fn dim(&self) -> usize {
2647        self.m * self.p
2648    }
2649
2650    fn as_any(&self) -> &(dyn std::any::Any + 'static) {
2651        self
2652    }
2653
2654    fn is_implicit(&self) -> bool {
2655        false
2656    }
2657
2658    fn mul_vec(&self, v: &Array1<f64>) -> Array1<f64> {
2659        let dim = self.m * self.p;
2660        assert_eq!(v.len(), dim);
2661        let design = self.design.view();
2662        let n = design.nrows();
2663        let (m, p) = (self.m, self.p);
2664        let mut out = Array1::<f64>::zeros(dim);
2665        let mut t = vec![0.0_f64; m];
2666        let mut u = vec![0.0_f64; m];
2667        for row in 0..n {
2668            // t[b] = X[row] · v_block_b
2669            for b in 0..m {
2670                let base = b * p;
2671                let mut acc = 0.0_f64;
2672                for i in 0..p {
2673                    acc += design[[row, i]] * v[base + i];
2674                }
2675                t[b] = acc;
2676            }
2677            // u[a] = Σ_b Ĵ[row,a,b] · t[b]
2678            for a in 0..m {
2679                let mut acc = 0.0_f64;
2680                for b in 0..m {
2681                    acc += self.jet[[row, a, b]] * t[b];
2682                }
2683                u[a] = acc;
2684            }
2685            // out[a·P+i] += u[a] · X[row,i]
2686            for a in 0..m {
2687                let ua = u[a];
2688                if ua == 0.0 {
2689                    continue;
2690                }
2691                let base = a * p;
2692                for i in 0..p {
2693                    out[base + i] += ua * design[[row, i]];
2694                }
2695            }
2696        }
2697        out
2698    }
2699
2700    fn projected_matrix(&self, factor: &Array2<f64>) -> Array2<f64> {
2701        let dim = self.m * self.p;
2702        assert_eq!(factor.nrows(), dim);
2703        let rank = factor.ncols();
2704        let design = self.design.view();
2705        let n = design.nrows();
2706        let (m, p) = (self.m, self.p);
2707        let mut out = Array2::<f64>::zeros((rank, rank));
2708        // g[a,k]  = X[row] · F_block_a[:,k]
2709        // jg[a,l] = Σ_b Ĵ[row,a,b] · g[b,l]
2710        let mut g = Array2::<f64>::zeros((m, rank));
2711        let mut jg = Array2::<f64>::zeros((m, rank));
2712        for row in 0..n {
2713            for a in 0..m {
2714                let base = a * p;
2715                for k in 0..rank {
2716                    let mut acc = 0.0_f64;
2717                    for i in 0..p {
2718                        acc += design[[row, i]] * factor[[base + i, k]];
2719                    }
2720                    g[[a, k]] = acc;
2721                }
2722            }
2723            for a in 0..m {
2724                for l in 0..rank {
2725                    let mut acc = 0.0_f64;
2726                    for b in 0..m {
2727                        acc += self.jet[[row, a, b]] * g[[b, l]];
2728                    }
2729                    jg[[a, l]] = acc;
2730                }
2731            }
2732            for k in 0..rank {
2733                for l in 0..rank {
2734                    let mut acc = 0.0_f64;
2735                    for a in 0..m {
2736                        acc += g[[a, k]] * jg[[a, l]];
2737                    }
2738                    out[[k, l]] += acc;
2739                }
2740            }
2741        }
2742        out
2743    }
2744
2745    fn trace_projected_factor(&self, factor: &Array2<f64>) -> f64 {
2746        // tr(Fᵀ B_d F) — exact, matching the dense `dense_trace_projected_factor`.
2747        self.projected_matrix(factor).diag().sum()
2748    }
2749
2750    fn to_dense(&self) -> Array2<f64> {
2751        // B_d[(a,i),(b,j)] = Σ_row Ĵ[row,a,b] · X[row,i] · X[row,j].
2752        let dim = self.m * self.p;
2753        let design = self.design.view();
2754        let n = design.nrows();
2755        let (m, p) = (self.m, self.p);
2756        let mut out = Array2::<f64>::zeros((dim, dim));
2757        for row in 0..n {
2758            for a in 0..m {
2759                for b in 0..m {
2760                    let jab = self.jet[[row, a, b]];
2761                    if jab == 0.0 {
2762                        continue;
2763                    }
2764                    let ra = a * p;
2765                    let rb = b * p;
2766                    for i in 0..p {
2767                        let xi = design[[row, i]];
2768                        if xi == 0.0 {
2769                            continue;
2770                        }
2771                        let scaled = jab * xi;
2772                        for j in 0..p {
2773                            out[[ra + i, rb + j]] += scaled * design[[row, j]];
2774                        }
2775                    }
2776                }
2777            }
2778        }
2779        out
2780    }
2781}
2782
2783#[cfg(test)]
2784mod tests {
2785    //! Identifiability + reference-class-gauge audit.
2786    //!
2787    //! The reference class `K − 1` carries `η ≡ 0` and is NOT represented
2788    //! as a parameter block — so the gauge is set entirely by the block
2789    //! layout. These tests pin three invariants the canonical
2790    //! [`gam_identifiability::canonical::canonicalize_for_identifiability`]
2791    //! step must preserve:
2792    //!
2793    //! 1. Block count `= K − 1` and block names `class_0 … class_{K-2}`.
2794    //! 2. Block ordering is class-order — never permuted.
2795    //! 3. `gauge_priority` is strictly decreasing in active-class index, so
2796    //!    the canonicaliser absorbs shared affine / null-space directions
2797    //!    onto the class farthest from the reference and the saved-model
2798    //!    `class_levels` order survives unchanged.
2799    use super::*;
2800    use gam_problem::DenseMatrixHyperOperator;
2801    use ndarray::array;
2802
2803    /// #932 production single-source parity: the live multinomial tower
2804    /// (`joint_loglik_and_gradient_from_probs`, `hessian_matvec_into_with_probs`,
2805    /// and the third/fourth `directional_fisher_jet_rows` /
2806    /// `second_directional_fisher_jet_rows` coefficient projections that the
2807    /// #1082 Jeffreys/Firth inner cycle runs) is pinned, by INVOKING PRODUCTION,
2808    /// against the universal gam-math jet — and against an independent
2809    /// finite-difference witness that never touches the jet.
2810    ///
2811    /// Production differentiates the one normalized-softmax Fisher expression
2812    /// through compact nilpotent channels; only the X-factored coefficient-space
2813    /// scatter is specialized. This module makes any dropped or sign-flipped
2814    /// coefficient loud without retaining separate production calculus.
2815    mod jet_single_source_932 {
2816        use super::*;
2817        use gam_math::jet_tower::{
2818            program_fourth_contracted, program_row_kernel, program_third_contracted,
2819        };
2820        use std::sync::Arc;
2821
2822        /// Build a single-row `K = M + 1` family with the design collapsed to the
2823        /// `1×1` identity (`P = 1`, `X = [[1.0]]`), so the coefficient-space
2824        /// directions the production kernels consume ARE the η-space directions —
2825        /// letting the per-row β-space kernels be compared to the jet's η-space
2826        /// contractions with no design projection in the way.
2827        fn single_row_family(obs: usize, w: f64, k: usize) -> MultinomialFamily {
2828            let mut y = Array2::<f64>::zeros((1, k));
2829            y[[0, obs]] = 1.0;
2830            let design = Arc::new(array![[1.0_f64]]);
2831            MultinomialFamily::new(y, array![w], k, design, Arc::new(Vec::new()))
2832                .expect("single-row multinomial family")
2833        }
2834
2835        fn single_row_family_response(response: &[f64], w: f64) -> MultinomialFamily {
2836            let y = Array2::from_shape_vec((1, response.len()), response.to_vec())
2837                .expect("single-row simplex response");
2838            MultinomialFamily::new(
2839                y,
2840                array![w],
2841                response.len(),
2842                Arc::new(array![[1.0_f64]]),
2843                Arc::new(Vec::new()),
2844            )
2845            .expect("single-row multinomial family with simplex response")
2846        }
2847
2848        /// Deterministic LCG (NO `rand`, NO clock seeding — #932 rules).
2849        struct Lcg(u64);
2850        impl Lcg {
2851            fn f64(&mut self) -> f64 {
2852                self.0 = self
2853                    .0
2854                    .wrapping_mul(6364136223846793005)
2855                    .wrapping_add(1442695040888963407);
2856                ((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
2857            }
2858            fn uniform(&mut self, lo: f64, hi: f64) -> f64 {
2859                lo + (hi - lo) * self.f64()
2860            }
2861        }
2862
2863        const JET_TOL: f64 = 1e-9;
2864
2865        fn close(a: f64, b: f64, tol: f64, label: &str) {
2866            let band = tol + tol * a.abs().max(b.abs());
2867            assert!(
2868                (a - b).abs() <= band,
2869                "{label}: {a:+.15e} vs {b:+.15e} (|Δ|={:.3e} band {band:.3e})",
2870                (a - b).abs()
2871            );
2872        }
2873
2874        /// Row probabilities over the `M` ACTIVE classes at raw η (reference class
2875        /// dropped), via the production softmax pass.
2876        fn active_probs<const M: usize>(
2877            family: &MultinomialFamily,
2878            eta: &[f64; M],
2879        ) -> ndarray::Array2<f64> {
2880            let eta2 = Array2::<f64>::from_shape_vec((1, M), eta.to_vec()).expect("eta (1,M)");
2881            family.row_probabilities(eta2.view())
2882        }
2883
2884        /// Production third `∂_dir H` at η: the per-row `M×M` Fisher jet, evaluated
2885        /// by the LIVE `directional_fisher_jet_rows`.
2886        fn prod_third<const M: usize>(
2887            family: &MultinomialFamily,
2888            eta: &[f64; M],
2889            dir: &[f64; M],
2890        ) -> [[f64; M]; M] {
2891            let probs = active_probs(family, eta);
2892            let d = Array1::from(dir.to_vec());
2893            let j = family.directional_fisher_jet_rows(probs.view(), &d);
2894            std::array::from_fn(|a| std::array::from_fn(|b| j[[0, a, b]]))
2895        }
2896
2897        /// Production fourth `∂_u ∂_v H` at η via the LIVE
2898        /// `second_directional_fisher_jet_rows`.
2899        fn prod_fourth<const M: usize>(
2900            family: &MultinomialFamily,
2901            eta: &[f64; M],
2902            u: &[f64; M],
2903            v: &[f64; M],
2904        ) -> [[f64; M]; M] {
2905            let probs = active_probs(family, eta);
2906            let ua = Array1::from(u.to_vec());
2907            let va = Array1::from(v.to_vec());
2908            let j = family.second_directional_fisher_jet_rows(probs.view(), &ua, &va);
2909            std::array::from_fn(|a| std::array::from_fn(|b| j[[0, a, b]]))
2910        }
2911
2912        /// Production Hessian block at η via the LIVE `hessian_matvec_into_with_probs`
2913        /// (column extraction against the `M` unit directions).
2914        fn prod_hessian<const M: usize>(
2915            family: &MultinomialFamily,
2916            eta: &[f64; M],
2917        ) -> [[f64; M]; M] {
2918            let probs = active_probs(family, eta);
2919            let mut h = [[0.0_f64; M]; M];
2920            for col in 0..M {
2921                let mut e = Array1::<f64>::zeros(M);
2922                e[col] = 1.0;
2923                let mut out = Array1::<f64>::zeros(M);
2924                family
2925                    .hessian_matvec_into_with_probs(probs.view(), &e, &mut out)
2926                    .expect("prod hessian matvec");
2927                for row in 0..M {
2928                    h[row][col] = out[row];
2929                }
2930            }
2931            h
2932        }
2933
2934        fn run_parity<const M: usize>(seed: u64) {
2935            let mut rng = Lcg(seed);
2936            for trial in 0..24 {
2937                let eta: [f64; M] = std::array::from_fn(|_| rng.uniform(-2.0, 2.0));
2938                let obs = trial % (M + 1);
2939                let w = rng.uniform(0.25, 2.5);
2940                let family = single_row_family(obs, w, M + 1);
2941                let mut response = vec![0.0; M + 1];
2942                response[obs] = 1.0;
2943                let prog =
2944                    crate::multinomial_reml::MultinomialLogitRowProgram::new(&eta, &response, w)
2945                        .expect("valid multinomial row program");
2946
2947                // ── Jet ORACLE vs LIVE production (≤1e-9) ──────────────────────
2948                let (jet_v, jet_g, jet_h) =
2949                    program_row_kernel::<M, _>(&prog, 0).expect("jet row kernel");
2950
2951                // Value + gradient from the live log-lik assembler (NLL = −log_lik,
2952                // ∇NLL = −∇log_lik).
2953                let probs = active_probs(&family, &eta);
2954                let eta_matrix = Array2::from_shape_vec((1, M), eta.to_vec()).expect("eta matrix");
2955                let (log_lik, grad_ll) = family
2956                    .joint_loglik_and_gradient_from_probs(eta_matrix.view(), probs.view())
2957                    .expect("valid frozen multinomial row");
2958                close(
2959                    jet_v,
2960                    -log_lik,
2961                    JET_TOL,
2962                    &format!("M={M} trial {trial} value"),
2963                );
2964                for a in 0..M {
2965                    close(
2966                        jet_g[a],
2967                        -grad_ll[a],
2968                        JET_TOL,
2969                        &format!("M={M} trial {trial} grad[{a}]"),
2970                    );
2971                }
2972
2973                // Hessian block from the live matvec.
2974                let prod_h = prod_hessian(&family, &eta);
2975                for a in 0..M {
2976                    for b in 0..M {
2977                        close(
2978                            jet_h[a][b],
2979                            prod_h[a][b],
2980                            JET_TOL,
2981                            &format!("M={M} trial {trial} H[{a}][{b}]"),
2982                        );
2983                    }
2984                }
2985
2986                // Third + fourth directional Fisher jets from the live generated expression.
2987                let dir: [f64; M] = std::array::from_fn(|_| rng.uniform(-1.5, 1.5));
2988                let u: [f64; M] = std::array::from_fn(|_| rng.uniform(-1.5, 1.5));
2989                let jet_third = program_third_contracted(&prog, 0, &dir).expect("jet third");
2990                let prod_t3 = prod_third(&family, &eta, &dir);
2991                let jet_fourth = program_fourth_contracted(&prog, 0, &u, &dir).expect("jet fourth");
2992                let prod_t4 = prod_fourth(&family, &eta, &u, &dir);
2993                for a in 0..M {
2994                    for b in 0..M {
2995                        close(
2996                            jet_third[a][b],
2997                            prod_t3[a][b],
2998                            JET_TOL,
2999                            &format!("M={M} trial {trial} third[{a}][{b}]"),
3000                        );
3001                        close(
3002                            jet_fourth[a][b],
3003                            prod_t4[a][b],
3004                            JET_TOL,
3005                            &format!("M={M} trial {trial} fourth[{a}][{b}]"),
3006                        );
3007                    }
3008                }
3009
3010                // ── Independent FINITE-DIFFERENCE witness (NO jet) ─────────────
3011                // ∂_dir H via central difference of the live Hessian block.
3012                let h_fd = 1e-4;
3013                let eta_p: [f64; M] = std::array::from_fn(|a| eta[a] + h_fd * dir[a]);
3014                let eta_m: [f64; M] = std::array::from_fn(|a| eta[a] - h_fd * dir[a]);
3015                let hp = prod_hessian(&family, &eta_p);
3016                let hm = prod_hessian(&family, &eta_m);
3017                for a in 0..M {
3018                    for b in 0..M {
3019                        let fd = (hp[a][b] - hm[a][b]) / (2.0 * h_fd);
3020                        close(
3021                            prod_t3[a][b],
3022                            fd,
3023                            1e-6,
3024                            &format!("M={M} trial {trial} FD third[{a}][{b}]"),
3025                        );
3026                    }
3027                }
3028                // ∂_u of the live third (fixed second direction `dir`) via central
3029                // difference reproduces the live fourth.
3030                let t3_up = prod_third(&family, &eta_p_along(&eta, &u, h_fd), &dir);
3031                let t3_um = prod_third(&family, &eta_m_along(&eta, &u, h_fd), &dir);
3032                for a in 0..M {
3033                    for b in 0..M {
3034                        let fd = (t3_up[a][b] - t3_um[a][b]) / (2.0 * h_fd);
3035                        close(
3036                            prod_t4[a][b],
3037                            fd,
3038                            1e-6,
3039                            &format!("M={M} trial {trial} FD fourth[{a}][{b}]"),
3040                        );
3041                    }
3042                }
3043            }
3044        }
3045
3046        fn eta_p_along<const M: usize>(eta: &[f64; M], u: &[f64; M], h: f64) -> [f64; M] {
3047            std::array::from_fn(|a| eta[a] + h * u[a])
3048        }
3049        fn eta_m_along<const M: usize>(eta: &[f64; M], u: &[f64; M], h: f64) -> [f64; M] {
3050            std::array::from_fn(|a| eta[a] - h * u[a])
3051        }
3052
3053        /// The LIVE multinomial value / gradient / Hessian / third / fourth hand
3054        /// tower reproduces the universal gam-math jet at ≤1e-9, AND the live
3055        /// third/fourth reproduce an independent central-difference of the live
3056        /// lower order — for `M = 2` (K=3) and `M = 3` (K=4).
3057        #[test]
3058        fn multinomial_live_tower_matches_jet_and_fd() {
3059            run_parity::<2>(0x9322_2020_0710_face);
3060            run_parity::<3>(0x0bad_c0de_0710_2020);
3061        }
3062
3063        /// Saturated active/reference classes and label-smoothed targets all use
3064        /// the same centered semantic expression. This catches the former
3065        /// probability-clamp split: values remain exact after a probability has
3066        /// underflowed to zero, while V/G/H/t3/t4 stay finite and agree with the
3067        /// production structure-compiled schedules.
3068        #[test]
3069        fn multinomial_extreme_tails_share_one_stable_row_program_932() {
3070            const M: usize = 3;
3071            let cases = [
3072                ([1_000.0, -1_000.0, -750.0], [0.0, 0.0, 0.0, 1.0], 1.25),
3073                ([-1_000.0, -900.0, -800.0], [0.0, 0.0, 1.0, 0.0], 0.75),
3074                ([1_000.0, 1_000.0, -1_000.0], [0.2, 0.3, 0.1, 0.4], 2.0),
3075                ([f64::MAX, -f64::MAX, 0.0], [1.0, 0.0, 0.0, 0.0], 1.0),
3076                ([f64::MAX, -f64::MAX, 0.0], [0.0, 0.0, 0.0, 1.0], 0.0),
3077            ];
3078            let direction = [0.7, -0.4, 1.1];
3079            let direction_u = [-0.3, 0.9, 0.2];
3080
3081            for (case, (eta, response, weight)) in cases.into_iter().enumerate() {
3082                let program = MultinomialLogitRowProgram::new(&eta, &response, weight)
3083                    .expect("valid extreme-tail row program");
3084                let (canonical_value, canonical_gradient, canonical_hessian) =
3085                    program_row_kernel::<3, _>(&program, 0).expect("canonical extreme-tail V/G/H");
3086                let canonical_third = program_third_contracted(&program, 0, &direction)
3087                    .expect("canonical extreme-tail third");
3088                let canonical_fourth =
3089                    program_fourth_contracted(&program, 0, &direction_u, &direction)
3090                        .expect("canonical extreme-tail fourth");
3091
3092                assert!(canonical_value.is_finite(), "case {case} value");
3093                assert!(
3094                    canonical_gradient.iter().all(|value| value.is_finite()),
3095                    "case {case} gradient"
3096                );
3097                assert!(
3098                    canonical_hessian
3099                        .iter()
3100                        .flatten()
3101                        .all(|value| value.is_finite()),
3102                    "case {case} Hessian"
3103                );
3104                assert!(
3105                    canonical_third
3106                        .iter()
3107                        .flatten()
3108                        .all(|value| value.is_finite()),
3109                    "case {case} third"
3110                );
3111                assert!(
3112                    canonical_fourth
3113                        .iter()
3114                        .flatten()
3115                        .all(|value| value.is_finite()),
3116                    "case {case} fourth"
3117                );
3118
3119                let family = single_row_family_response(&response, weight);
3120                let eta_matrix =
3121                    Array2::from_shape_vec((1, M), eta.to_vec()).expect("tail eta matrix");
3122                let response_matrix = Array2::from_shape_vec((1, M + 1), response.to_vec())
3123                    .expect("tail response matrix");
3124                let (live_log_likelihood, live_gradient, live_hessian) = family
3125                    .likelihood
3126                    .value_gradient_hessian(eta_matrix.view(), response_matrix.view())
3127                    .expect("valid multinomial tail row");
3128                close(
3129                    canonical_value,
3130                    -live_log_likelihood,
3131                    1.0e-12,
3132                    &format!("tail case {case} value"),
3133                );
3134                for row in 0..M {
3135                    close(
3136                        canonical_gradient[row],
3137                        -live_gradient[[0, row]],
3138                        1.0e-12,
3139                        &format!("tail case {case} gradient[{row}]"),
3140                    );
3141                    for column in 0..M {
3142                        close(
3143                            canonical_hessian[row][column],
3144                            live_hessian[[0, row, column]],
3145                            1.0e-12,
3146                            &format!("tail case {case} Hessian[{row}][{column}]"),
3147                        );
3148                    }
3149                }
3150
3151                let live_third = prod_third(&family, &eta, &direction);
3152                let live_fourth = prod_fourth(&family, &eta, &direction_u, &direction);
3153                for row in 0..M {
3154                    for column in 0..M {
3155                        close(
3156                            canonical_third[row][column],
3157                            live_third[row][column],
3158                            1.0e-12,
3159                            &format!("tail case {case} third[{row}][{column}]"),
3160                        );
3161                        close(
3162                            canonical_fourth[row][column],
3163                            live_fourth[row][column],
3164                            1.0e-12,
3165                            &format!("tail case {case} fourth[{row}][{column}]"),
3166                        );
3167                    }
3168                }
3169            }
3170        }
3171
3172        /// The target-shaped M=32 storage schedules must remain an exact lowering
3173        /// of the canonical multinomial row program. This invokes the live
3174        /// `directional_fisher_jet_rows` and `second_directional_fisher_jet_rows`
3175        /// production entries, so x86-64-v3 exercises the contiguous first-order
3176        /// schedule while AVX-512-native builds exercise the symmetric static
3177        /// schedule. Mixed-second output is symmetric on both targets. The
3178        /// worker's 1 MiB stack is deliberately smaller than the 1,082,368-byte
3179        /// `TwoSeed<32>` primary array: passing proves the canonical evaluator
3180        /// selected its bounded heap storage rather than relying on test-runner
3181        /// stack configuration.
3182        #[test]
3183        fn multinomial_m32_production_directional_routes_match_canonical_jet_932() {
3184            const REGRESSION_STACK_BYTES: usize = 1024 * 1024;
3185            let worker = std::thread::Builder::new()
3186                .name("multinomial-m32-canonical-stack-bound".to_string())
3187                .stack_size(REGRESSION_STACK_BYTES)
3188                .spawn(|| {
3189                    const M: usize = 32;
3190                    assert_eq!(
3191                        M * std::mem::size_of::<gam_math::jet_scalar::TwoSeed<M>>(),
3192                        1_082_368,
3193                        "M=32 canonical fourth-order seed footprint changed"
3194                    );
3195                    let first_schedule = fisher_output_schedule::<OneSeed<0>>(M);
3196                    let expected_first = if AVX2_WITHOUT_AVX512 {
3197                        FisherOutputSchedule::ContiguousFull
3198                    } else {
3199                        FisherOutputSchedule::SymmetricTriangle
3200                    };
3201                    assert!(
3202                        first_schedule == expected_first,
3203                        "M=32 first-directional Fisher schedule does not match the target ISA"
3204                    );
3205                    assert!(
3206                        fisher_output_schedule::<TwoSeed<0>>(M)
3207                            == FisherOutputSchedule::SymmetricTriangle,
3208                        "M=32 second-directional Fisher schedule must retain symmetric output"
3209                    );
3210
3211                    for trial in 0..4 {
3212                        let eta: [f64; M] = std::array::from_fn(|axis| {
3213                            0.9 * ((axis * 7 + trial * 3 + 1) as f64 * 0.17).sin()
3214                                - 0.35 * ((axis + trial + 2) as f64 * 0.11).cos()
3215                        });
3216                        let direction: [f64; M] = std::array::from_fn(|axis| {
3217                            0.7 * ((axis * 5 + trial + 3) as f64 * 0.13).cos()
3218                                - 0.2 * ((axis + 2 * trial + 1) as f64 * 0.19).sin()
3219                        });
3220                        let direction_u: [f64; M] = std::array::from_fn(|axis| {
3221                            -0.6 * ((axis * 3 + trial + 4) as f64 * 0.09).sin()
3222                                + 0.25 * ((axis + trial + 5) as f64 * 0.23).cos()
3223                        });
3224                        let observed_class = if trial % 2 == 0 { trial } else { M };
3225                        let weight = 0.8 + 0.3 * trial as f64;
3226                        let family = single_row_family(observed_class, weight, M + 1);
3227                        let mut response = vec![0.0; M + 1];
3228                        response[observed_class] = 1.0;
3229                        let program = MultinomialLogitRowProgram::new(&eta, &response, weight)
3230                            .expect("valid M=32 multinomial row program");
3231
3232                        let production_first = prod_third(&family, &eta, &direction);
3233                        let canonical_first = program_third_contracted(&program, 0, &direction)
3234                            .expect("canonical M=32 first-directional Fisher contraction");
3235                        let production_second =
3236                            prod_fourth(&family, &eta, &direction_u, &direction);
3237                        let canonical_second =
3238                            program_fourth_contracted(&program, 0, &direction_u, &direction)
3239                                .expect("canonical M=32 second-directional Fisher contraction");
3240
3241                        for row in 0..M {
3242                            for column in 0..M {
3243                                close(
3244                                    production_first[row][column],
3245                                    canonical_first[row][column],
3246                                    JET_TOL,
3247                                    &format!(
3248                                        "M=32 trial {trial} first-directional[{row}][{column}]"
3249                                    ),
3250                                );
3251                                close(
3252                                    production_second[row][column],
3253                                    canonical_second[row][column],
3254                                    JET_TOL,
3255                                    &format!(
3256                                        "M=32 trial {trial} second-directional[{row}][{column}]"
3257                                    ),
3258                                );
3259                            }
3260                        }
3261                    }
3262                })
3263                .expect("spawn bounded-stack M=32 parity worker");
3264            if let Err(payload) = worker.join() {
3265                std::panic::resume_unwind(payload);
3266            }
3267        }
3268
3269        /// #932 release speed gate for the multinomial-logit row. Production
3270        /// is the structure-compiled softmax lowering
3271        /// ([`MultinomialLogitRowProgram::value_gradient_hessian_into`], with
3272        /// const-hinted small-`M` shapes of its single body), timed against
3273        /// the generic gam-math forward-mode jet tower
3274        /// ([`program_row_kernel`]) — the naive automatic-differentiation
3275        /// baseline the retained specialization must beat, since #932 removed
3276        /// this family's `cfg(test)` hand restatement. Emits the
3277        /// harness-parsed `hand_over_production` token (generic-tower time
3278        /// over production time) per active-class width; the MSI release
3279        /// harness fails closed whenever any measured cell is `<= 1`.
3280        ///
3281        /// The batch of distinct rows supplies genuine per-row input variation, so
3282        /// the optimizer cannot hoist the pure row call out of the sweep, and the
3283        /// finite checksum over every returned channel keeps the whole sweep live
3284        /// without `std::hint::black_box`.
3285        #[test]
3286        fn release_measure_multinomial_specialized_vs_generic_tower_932() {
3287            fn measure<const M: usize>(seed: u64) {
3288                use std::time::Instant;
3289
3290                const ROWS: usize = 512;
3291                let mut rng = Lcg(seed);
3292                let mut etas: Vec<[f64; M]> = Vec::with_capacity(ROWS);
3293                let mut responses: Vec<Vec<f64>> = Vec::with_capacity(ROWS);
3294                let mut weights: Vec<f64> = Vec::with_capacity(ROWS);
3295                for row in 0..ROWS {
3296                    let eta: [f64; M] = std::array::from_fn(|_| rng.uniform(-2.5, 2.5));
3297                    let observed = row % (M + 1);
3298                    let mut response = vec![0.0; M + 1];
3299                    response[observed] = 1.0;
3300                    etas.push(eta);
3301                    responses.push(response);
3302                    weights.push(rng.uniform(0.25, 2.5));
3303                }
3304                let programs: Vec<MultinomialLogitRowProgram> = (0..ROWS)
3305                    .map(|row| {
3306                        MultinomialLogitRowProgram::new(&etas[row], &responses[row], weights[row])
3307                            .expect("valid multinomial batch row")
3308                    })
3309                    .collect();
3310
3311                let mut probabilities = vec![0.0_f64; M + 1];
3312                let mut gradient = vec![0.0_f64; M];
3313                let mut hessian = vec![0.0_f64; M * M];
3314
3315                // Warm both paths and pin that the production lowering and the
3316                // generic tower emit the same V/G/H, so the two timings measure
3317                // equal work.
3318                for program in &programs {
3319                    let (tower_value, tower_gradient, tower_hessian) =
3320                        program_row_kernel::<M, _>(program, 0).expect("tower warm kernel");
3321                    let production_value = program.value_gradient_hessian_into(
3322                        &mut probabilities,
3323                        &mut gradient,
3324                        &mut hessian,
3325                    );
3326                    close(
3327                        tower_value,
3328                        production_value,
3329                        JET_TOL,
3330                        &format!("M={M} release-measure value parity"),
3331                    );
3332                    for a in 0..M {
3333                        close(
3334                            tower_gradient[a],
3335                            gradient[a],
3336                            JET_TOL,
3337                            &format!("M={M} release-measure gradient[{a}] parity"),
3338                        );
3339                        for b in 0..M {
3340                            close(
3341                                tower_hessian[a][b],
3342                                hessian[a * M + b],
3343                                JET_TOL,
3344                                &format!("M={M} release-measure hessian[{a}][{b}] parity"),
3345                            );
3346                        }
3347                    }
3348                }
3349
3350                let best_secs = |sweep: &mut dyn FnMut() -> f64| -> f64 {
3351                    let mut best = f64::INFINITY;
3352                    for _ in 0..5 {
3353                        let started = Instant::now();
3354                        let checksum = sweep();
3355                        assert!(
3356                            checksum.is_finite(),
3357                            "multinomial release-measure checksum must stay finite"
3358                        );
3359                        best = best.min(started.elapsed().as_secs_f64());
3360                    }
3361                    best
3362                };
3363
3364                let mut production_sweep = || {
3365                    let mut checksum = 0.0_f64;
3366                    for program in &programs {
3367                        let value = program.value_gradient_hessian_into(
3368                            &mut probabilities,
3369                            &mut gradient,
3370                            &mut hessian,
3371                        );
3372                        checksum += value + gradient[0] + hessian[0];
3373                    }
3374                    checksum
3375                };
3376                let production_secs = best_secs(&mut production_sweep);
3377
3378                let mut tower_sweep = || {
3379                    let mut checksum = 0.0_f64;
3380                    for program in &programs {
3381                        let (value, tower_gradient, tower_hessian) =
3382                            program_row_kernel::<M, _>(program, 0).expect("tower kernel");
3383                        checksum += value + tower_gradient[0] + tower_hessian[0][0];
3384                    }
3385                    checksum
3386                };
3387                let tower_secs = best_secs(&mut tower_sweep);
3388
3389                let production_ns = production_secs * 1e9 / ROWS as f64;
3390                let tower_ns = tower_secs * 1e9 / ROWS as f64;
3391                eprintln!(
3392                    "MULTINOMIAL-RELEASE-932 M={M} rows={ROWS} production_ns={production_ns:.3} \
3393                     generic_tower_ns={tower_ns:.3} hand_over_production={:.6}",
3394                    tower_ns / production_ns,
3395                );
3396            }
3397
3398            measure::<2>(0x9322_2020_0715_face);
3399            measure::<3>(0x0bad_c0de_0715_2020);
3400            measure::<4>(0x5eed_4444_0722_beef);
3401            measure::<8>(0x1234_5678_0715_abcd);
3402        }
3403    }
3404
3405    impl MultinomialFamily {
3406        /// Test-only convenience wrapper: assemble the batched first-directional
3407        /// derivatives directly from `eta`, computing the row probabilities
3408        /// internally. Production callers already hold the probabilities and use
3409        /// `assemble_directional_derivatives_from_probs`; the parity tests in this
3410        /// module drive the family from raw `eta`.
3411        fn assemble_directional_derivatives(
3412            &self,
3413            eta: ArrayView2<'_, f64>,
3414            directions: &[Array1<f64>],
3415        ) -> Result<Vec<Array2<f64>>, String> {
3416            let probs = self.row_probabilities(eta);
3417            self.assemble_directional_derivatives_from_probs(probs.view(), directions)
3418        }
3419
3420        /// Assemble `D_beta H[d_j]` for an arbitrary batch of coefficient
3421        /// directions in one shared softmax/probability pass.
3422        ///
3423        /// This is the outer-LAML mode-response counterpart to
3424        /// [`Self::assemble_all_axis_directional_derivatives`]: the directions are
3425        /// not canonical axes, but the row probabilities and design outer products
3426        /// are identical for every `d_j` at a frozen beta. Sharing that row sweep is
3427        /// the #1082 penguin lever; the old path rebuilt the softmax jet and dense
3428        /// Gram once per outer coordinate.
3429        ///
3430        /// #932 cutover: this dense block assembly is no longer on the production
3431        /// outer-Hessian path (the matrix-free `MultinomialDirectionalHyperOperator`
3432        /// replaced it). It lives here in the test module as the reference the
3433        /// ≤1e-10 parity oracle contracts the matrix-free operator against.
3434        fn assemble_directional_derivatives_from_probs(
3435            &self,
3436            probs_full: ArrayView2<'_, f64>,
3437            directions: &[Array1<f64>],
3438        ) -> Result<Vec<Array2<f64>>, String> {
3439            use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
3440
3441            let n_dirs = directions.len();
3442            if n_dirs == 0 {
3443                return Ok(Vec::new());
3444            }
3445            let n = self.weights.len();
3446            let p = self.design.ncols();
3447            let m = self.active_classes();
3448            let dim = m * p;
3449            for (idx, direction) in directions.iter().enumerate() {
3450                if direction.len() != dim {
3451                    return Err(format!(
3452                        "MultinomialFamily batched direction {idx} length {} != (K-1)·P = {dim}",
3453                        direction.len()
3454                    ));
3455                }
3456            }
3457            let design = self.design.view();
3458            // #1082: parallelise over the DIRECTION batch instead of rows, dropping
3459            // the `n_dirs·dim·dim` per-worker accumulator + `reduce` (see the note on
3460            // `assemble_all_axis_directional_derivatives`). Each direction owns one
3461            // `dim·dim` block and scans all rows independently; the per-row
3462            // arithmetic is unchanged (only the row-summation order differs, admitted
3463            // to 1e-10 by the batched-vs-per-direction parity test).
3464            let out: Vec<Array2<f64>> = directions
3465                .par_iter()
3466                .map(|direction| {
3467                    let mut mat = vec![0.0_f64; dim * dim];
3468                    let mut d_eta = vec![0.0_f64; m];
3469                    let mut dp = vec![0.0_f64; m];
3470                    for row in 0..n {
3471                        let w = self.weights[row];
3472                        if w == 0.0 {
3473                            continue;
3474                        }
3475                        let mut s = 0.0_f64;
3476                        for a in 0..m {
3477                            let base = a * p;
3478                            let mut eta_dir = 0.0_f64;
3479                            for i in 0..p {
3480                                eta_dir += design[[row, i]] * direction[base + i];
3481                            }
3482                            d_eta[a] = eta_dir;
3483                            s += probs_full[[row, a]] * eta_dir;
3484                        }
3485                        for a in 0..m {
3486                            dp[a] = probs_full[[row, a]] * (d_eta[a] - s);
3487                        }
3488
3489                        for a in 0..m {
3490                            let pa = probs_full[[row, a]];
3491                            let row_a = a * p;
3492                            let jaa = w * (dp[a] - 2.0 * dp[a] * pa);
3493                            if jaa != 0.0 {
3494                                for i in 0..p {
3495                                    let xi = design[[row, i]];
3496                                    if xi == 0.0 {
3497                                        continue;
3498                                    }
3499                                    let scaled = jaa * xi;
3500                                    let out_row = (row_a + i) * dim;
3501                                    for j in 0..p {
3502                                        mat[out_row + row_a + j] += scaled * design[[row, j]];
3503                                    }
3504                                }
3505                            }
3506                            for b in (a + 1)..m {
3507                                let pb = probs_full[[row, b]];
3508                                let jab = w * (-(dp[a] * pb + pa * dp[b]));
3509                                if jab == 0.0 {
3510                                    continue;
3511                                }
3512                                let row_b = b * p;
3513                                for i in 0..p {
3514                                    let xi = design[[row, i]];
3515                                    if xi == 0.0 {
3516                                        continue;
3517                                    }
3518                                    let scaled = jab * xi;
3519                                    let out_a = (row_a + i) * dim;
3520                                    let out_b = (row_b + i) * dim;
3521                                    for j in 0..p {
3522                                        let xj = design[[row, j]];
3523                                        let value = scaled * xj;
3524                                        mat[out_a + row_b + j] += value;
3525                                        mat[out_b + row_a + j] += value;
3526                                    }
3527                                }
3528                            }
3529                        }
3530                    }
3531                    let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
3532                        .expect("batched direction derivative buffer is dim·dim");
3533                    for i in 0..dim {
3534                        for j in (i + 1)..dim {
3535                            let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
3536                            mat[[i, j]] = avg;
3537                            mat[[j, i]] = avg;
3538                        }
3539                    }
3540                    mat
3541                })
3542                .collect();
3543            Ok(out)
3544        }
3545
3546        /// Assemble `D²_beta H[u_j, v_j]` for an arbitrary batch of coefficient
3547        /// direction pairs in one shared probability/design row sweep.
3548        ///
3549        /// The exact outer Hessian asks for one correction per ρ-pair, where both
3550        /// directions are mode responses rather than canonical axes. The old
3551        /// workspace default delegated each pair to
3552        /// [`Self::second_directional_fisher_jet`] plus `dense_block_xtwx`, rebuilding
3553        /// the same softmax probabilities and design Gram scatter for every pair.
3554        /// This fused path keeps the singular formula but amortizes the row walk
3555        /// across the whole `K(K+1)/2` pair batch (#1082).
3556        ///
3557        /// #932 cutover: test-module reference, the parity oracle's dense
3558        /// reference (see `assemble_directional_derivatives_from_probs`).
3559        fn assemble_second_directional_derivatives_from_probs(
3560            &self,
3561            probs_full: ArrayView2<'_, f64>,
3562            pairs: &[(Array1<f64>, Array1<f64>)],
3563        ) -> Result<Vec<Array2<f64>>, String> {
3564            use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
3565
3566            let n_pairs = pairs.len();
3567            if n_pairs == 0 {
3568                return Ok(Vec::new());
3569            }
3570            let n = self.weights.len();
3571            let p = self.design.ncols();
3572            let m = self.active_classes();
3573            let dim = m * p;
3574            for (idx, (u, v)) in pairs.iter().enumerate() {
3575                if u.len() != dim || v.len() != dim {
3576                    return Err(format!(
3577                        "MultinomialFamily batched second-directional pair {idx} lengths {} and {} != (K-1)·P = {dim}",
3578                        u.len(),
3579                        v.len()
3580                    ));
3581                }
3582            }
3583
3584            let design = self.design.view();
3585            // #1082: parallelise over the PAIR batch instead of rows, dropping the
3586            // `n_pairs·dim·dim` per-worker accumulator + `reduce` (this is the exact
3587            // outer Hessian's `K(K+1)/2` pair walk; see the note on
3588            // `assemble_all_axis_directional_derivatives`). Each pair owns one
3589            // `dim·dim` block and scans all rows independently; the per-row
3590            // arithmetic is unchanged (only the row-summation order differs, admitted
3591            // to 1e-10 by the workspace-batched-vs-per-pair parity test).
3592            let out: Vec<Array2<f64>> = pairs
3593                .par_iter()
3594                .map(|(u, v)| {
3595                    let mut mat = vec![0.0_f64; dim * dim];
3596                    let mut d_eta_u = vec![0.0_f64; m];
3597                    let mut d_eta_v = vec![0.0_f64; m];
3598                    let mut dp_u = vec![0.0_f64; m];
3599                    let mut dp_v = vec![0.0_f64; m];
3600                    let mut ddp = vec![0.0_f64; m];
3601                    for row in 0..n {
3602                        let w = self.weights[row];
3603                        if w == 0.0 {
3604                            continue;
3605                        }
3606                        let mut s_u = 0.0_f64;
3607                        let mut s_v = 0.0_f64;
3608                        for a in 0..m {
3609                            let base = a * p;
3610                            let mut eta_u = 0.0_f64;
3611                            let mut eta_v = 0.0_f64;
3612                            for i in 0..p {
3613                                let x = design[[row, i]];
3614                                eta_u += x * u[base + i];
3615                                eta_v += x * v[base + i];
3616                            }
3617                            d_eta_u[a] = eta_u;
3618                            d_eta_v[a] = eta_v;
3619                            s_u += probs_full[[row, a]] * eta_u;
3620                            s_v += probs_full[[row, a]] * eta_v;
3621                        }
3622
3623                        for a in 0..m {
3624                            let pa = probs_full[[row, a]];
3625                            dp_u[a] = pa * (d_eta_u[a] - s_u);
3626                            dp_v[a] = pa * (d_eta_v[a] - s_v);
3627                        }
3628
3629                        let mut ds_u_dv = 0.0_f64;
3630                        for a in 0..m {
3631                            ds_u_dv += dp_v[a] * d_eta_u[a];
3632                        }
3633                        for a in 0..m {
3634                            let pa = probs_full[[row, a]];
3635                            ddp[a] = dp_v[a] * (d_eta_u[a] - s_u) - pa * ds_u_dv;
3636                        }
3637
3638                        for a in 0..m {
3639                            let pa = probs_full[[row, a]];
3640                            let row_a = a * p;
3641                            let jaa = w * (ddp[a] - 2.0 * ddp[a] * pa - 2.0 * dp_u[a] * dp_v[a]);
3642                            if jaa != 0.0 {
3643                                for i in 0..p {
3644                                    let xi = design[[row, i]];
3645                                    if xi == 0.0 {
3646                                        continue;
3647                                    }
3648                                    let scaled = jaa * xi;
3649                                    let out_row = (row_a + i) * dim;
3650                                    for j in 0..p {
3651                                        mat[out_row + row_a + j] += scaled * design[[row, j]];
3652                                    }
3653                                }
3654                            }
3655
3656                            for b in (a + 1)..m {
3657                                let pb = probs_full[[row, b]];
3658                                let jab = -w
3659                                    * (ddp[a] * pb
3660                                        + dp_u[a] * dp_v[b]
3661                                        + dp_v[a] * dp_u[b]
3662                                        + pa * ddp[b]);
3663                                if jab == 0.0 {
3664                                    continue;
3665                                }
3666                                let row_b = b * p;
3667                                for i in 0..p {
3668                                    let xi = design[[row, i]];
3669                                    if xi == 0.0 {
3670                                        continue;
3671                                    }
3672                                    let scaled = jab * xi;
3673                                    let out_a = (row_a + i) * dim;
3674                                    let out_b = (row_b + i) * dim;
3675                                    for j in 0..p {
3676                                        let xj = design[[row, j]];
3677                                        let value = scaled * xj;
3678                                        mat[out_a + row_b + j] += value;
3679                                        mat[out_b + row_a + j] += value;
3680                                    }
3681                                }
3682                            }
3683                        }
3684                    }
3685                    let mut mat = Array2::<f64>::from_shape_vec((dim, dim), mat)
3686                        .expect("batched second-directional buffer is dim·dim");
3687                    for i in 0..dim {
3688                        for j in (i + 1)..dim {
3689                            let avg = 0.5 * (mat[[i, j]] + mat[[j, i]]);
3690                            mat[[i, j]] = avg;
3691                            mat[[j, i]] = avg;
3692                        }
3693                    }
3694                    mat
3695                })
3696                .collect();
3697            Ok(out)
3698        }
3699    }
3700
3701    fn toy_family(n_obs: usize, p: usize, k: usize) -> MultinomialFamily {
3702        let y = {
3703            let mut y = Array2::<f64>::zeros((n_obs, k));
3704            for i in 0..n_obs {
3705                y[[i, i % k]] = 1.0;
3706            }
3707            y
3708        };
3709        let weights = Array1::<f64>::ones(n_obs);
3710        let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
3711            ((i + j + 1) as f64).sin()
3712        }));
3713        let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(
3714            Array2::<f64>::from_shape_fn((p, p), |(i, j)| if i == j { 1.0 } else { 0.0 }),
3715        )]);
3716        MultinomialFamily::new(y, weights, k, design, penalties)
3717            .expect("toy MultinomialFamily must construct")
3718    }
3719
3720    #[test]
3721    fn block_specs_have_one_per_active_class_in_order() {
3722        let family = toy_family(8, 3, 4);
3723        let specs = family.build_block_specs();
3724        assert_eq!(specs.len(), 3, "expected K-1 = 3 active blocks for K=4");
3725        for (a, spec) in specs.iter().enumerate() {
3726            assert_eq!(spec.name, format!("class_{a}"));
3727        }
3728    }
3729
3730    #[test]
3731    fn gauge_priority_is_strictly_decreasing_in_class_index() {
3732        let family = toy_family(8, 3, 5);
3733        let specs = family.build_block_specs();
3734        for window in specs.windows(2) {
3735            assert!(
3736                window[0].gauge_priority > window[1].gauge_priority,
3737                "class_{} priority {} must exceed class_{} priority {}",
3738                window[0].name,
3739                window[0].gauge_priority,
3740                window[1].name,
3741                window[1].gauge_priority,
3742            );
3743        }
3744    }
3745
3746    #[test]
3747    fn block_specs_share_design_shape_with_family() {
3748        let family = toy_family(8, 3, 4);
3749        let specs = family.build_block_specs();
3750        let (n, p) = (family.design.nrows(), family.design.ncols());
3751        for spec in &specs {
3752            assert_eq!(spec.design.nrows(), n);
3753            assert_eq!(spec.design.ncols(), p);
3754        }
3755    }
3756
3757    #[test]
3758    fn per_term_smoothing_is_carried_by_equivariant_class_penalties() {
3759        let single = toy_family(6, 4, 3);
3760        for spec in &single.build_block_specs() {
3761            assert!(
3762                spec.penalties.is_empty()
3763                    && spec.initial_log_lambdas.is_empty()
3764                    && spec.nullspace_dims.is_empty(),
3765                "per-class blocks must attach no smooth penalty — the ALR-anchored \
3766                 per-block carrier is reference-dependent (#1587); the equivariant \
3767                 per-class centered joint family is the sole carrier"
3768            );
3769        }
3770        let joint = single.joint_penalty_specs().expect("joint specs");
3771        assert_eq!(
3772            joint.len(),
3773            3, // K = 3 per-class specs for the single term
3774            "one per-class centered penalty per (term, class), reference included"
3775        );
3776
3777        let p = 5;
3778        let k = 4;
3779        let n_terms = 3;
3780        let n_obs = 9;
3781        let y = {
3782            let mut y = Array2::<f64>::zeros((n_obs, k));
3783            for i in 0..n_obs {
3784                y[[i, i % k]] = 1.0;
3785            }
3786            y
3787        };
3788        let weights = Array1::<f64>::ones(n_obs);
3789        let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
3790            ((i + j + 1) as f64).cos()
3791        }));
3792        let penalties = Arc::new(
3793            (0..n_terms)
3794                .map(|t| {
3795                    crate::custom_family::PenaltyMatrix::Dense(Array2::<f64>::from_shape_fn(
3796                        (p, p),
3797                        |(i, j)| if i == j { (t + 1) as f64 } else { 0.0 },
3798                    ))
3799                })
3800                .collect::<Vec<_>>(),
3801        );
3802        let multi = MultinomialFamily::new(y, weights, k, design, penalties)
3803            .expect("multi-term MultinomialFamily must construct");
3804        let specs = multi.build_block_specs();
3805        assert_eq!(specs.len(), k - 1, "one block per active class");
3806        for spec in &specs {
3807            assert!(spec.penalties.is_empty());
3808            assert!(spec.initial_log_lambdas.is_empty());
3809            assert!(spec.nullspace_dims.is_empty());
3810        }
3811        let joint = multi.joint_penalty_specs().expect("joint specs");
3812        assert_eq!(
3813            joint.len(),
3814            n_terms * k,
3815            "K per-class centered penalties per term, term-major"
3816        );
3817        let m = k - 1;
3818        let raw_total = m * p;
3819        for (t_idx, term_specs) in joint.chunks(k).enumerate() {
3820            // Equal λ across the K per-class specs must reproduce the shared
3821            // centered metric penalty M ⊗ S_t exactly: Σ_c C_cᵀC_c = I − J/K.
3822            let mut sum = Array2::<f64>::zeros((raw_total, raw_total));
3823            for (c, spec) in term_specs.iter().enumerate() {
3824                assert_eq!(
3825                    spec.label.as_deref(),
3826                    Some(format!("multinomial_term_{t_idx}_class_{c}").as_str())
3827                );
3828                // rank(C_cᵀC_c ⊗ S_t) = rank(S_t) = p (diagonal PD fixtures).
3829                assert_eq!(spec.nullspace_dim, raw_total - p);
3830                sum += &spec.matrix;
3831            }
3832            let centered = multi
3833                .centered_joint_penalty_specs()
3834                .expect("centered specs");
3835            let target = &centered[t_idx].matrix;
3836            let max_err = sum
3837                .iter()
3838                .zip(target.iter())
3839                .map(|(a, b)| (a - b).abs())
3840                .fold(0.0_f64, f64::max);
3841            assert!(
3842                max_err < 1e-14,
3843                "Σ_c C_cᵀC_c ⊗ S_t must equal M ⊗ S_t (max err {max_err:.2e})"
3844            );
3845        }
3846    }
3847
3848    #[test]
3849    fn block_specs_keep_independent_lambda_per_class_and_term() {
3850        let p = 5;
3851        let k = 4;
3852        let n_terms = 3;
3853        let n_obs = 9;
3854        let y = {
3855            let mut y = Array2::<f64>::zeros((n_obs, k));
3856            for i in 0..n_obs {
3857                y[[i, i % k]] = 1.0;
3858            }
3859            y
3860        };
3861        let weights = Array1::<f64>::ones(n_obs);
3862        let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
3863            ((i + j + 1) as f64).cos()
3864        }));
3865        let penalties = Arc::new(
3866            (0..n_terms)
3867                .map(|t| {
3868                    crate::custom_family::PenaltyMatrix::Dense(Array2::<f64>::from_shape_fn(
3869                        (p, p),
3870                        |(i, j)| if i == j { (t + 1) as f64 } else { 0.0 },
3871                    ))
3872                })
3873                .collect::<Vec<_>>(),
3874        );
3875        let multi = MultinomialFamily::new(y, weights, k, design, penalties)
3876            .expect("multi-term MultinomialFamily must construct");
3877        let specs = multi.build_block_specs();
3878        assert_eq!(specs.len(), k - 1);
3879        // Independent per-class smoothness survives as one λ_{t,c} per (term,
3880        // class) on the CENTERED class functions — a gauge-free coordinate per
3881        // class — never as per-block ALR penalties (reference-anchored, #1587).
3882        let joint = multi.joint_penalty_specs().expect("joint specs");
3883        assert_eq!(joint.len(), n_terms * k);
3884        let labels: Vec<&str> = joint.iter().filter_map(|s| s.label.as_deref()).collect();
3885        assert_eq!(
3886            labels.len(),
3887            n_terms * k,
3888            "every spec carries its own label"
3889        );
3890        let unique: std::collections::HashSet<&str> = labels.iter().copied().collect();
3891        assert_eq!(
3892            unique.len(),
3893            labels.len(),
3894            "distinct labels ⇒ one independent outer λ per (term, class)"
3895        );
3896        for spec in &specs {
3897            assert!(spec.penalties.is_empty());
3898        }
3899    }
3900
3901    #[test]
3902    fn collect_eta_matrix_rejects_wrong_block_count() {
3903        let family = toy_family(4, 2, 3);
3904        let single = vec![ParameterBlockState {
3905            beta: Array1::<f64>::zeros(2),
3906            eta: Array1::<f64>::zeros(4),
3907        }];
3908        assert!(family.collect_eta_matrix(&single).is_err());
3909    }
3910
3911    #[test]
3912    fn evaluate_uniform_eta_zero_matches_uniform_softmax() {
3913        let family = toy_family(5, 2, 3);
3914        let p = family.design.ncols();
3915        let m = family.active_classes();
3916        let n = family.weights.len();
3917        let block_states: Vec<ParameterBlockState> = (0..m)
3918            .map(|_| ParameterBlockState {
3919                beta: Array1::<f64>::zeros(p),
3920                eta: Array1::<f64>::zeros(n),
3921            })
3922            .collect();
3923        let eval = family
3924            .evaluate(&block_states)
3925            .expect("baseline evaluate must succeed at β = 0");
3926        let expected = (n as f64) * (1.0 / (family.total_classes as f64)).ln();
3927        let diff = (eval.log_likelihood - expected).abs();
3928        assert!(
3929            diff < 1.0e-10,
3930            "baseline log-lik {} != {}",
3931            eval.log_likelihood,
3932            expected,
3933        );
3934        assert_eq!(eval.blockworking_sets.len(), m);
3935    }
3936
3937    #[test]
3938    fn directional_fisher_jet_along_zero_vanishes() {
3939        let family = toy_family(4, 2, 3);
3940        let p = family.design.ncols();
3941        let m = family.active_classes();
3942        let n = family.weights.len();
3943        let eta = Array2::<f64>::zeros((n, m));
3944        let d_beta = Array1::<f64>::zeros(m * p);
3945        let jet = family
3946            .directional_fisher_jet(eta.view(), &d_beta)
3947            .expect("zero direction must be valid");
3948        for &v in jet.iter() {
3949            assert!(v.abs() < 1.0e-14, "expected zero kernel, got {v}");
3950        }
3951    }
3952
3953    #[test]
3954    fn beta_flat_dim_equals_active_classes_times_p() {
3955        let family = toy_family(3, 5, 4);
3956        assert_eq!(family.beta_flat_dim(), 3 * 5);
3957    }
3958
3959    #[test]
3960    fn matrix_free_matvec_matches_dense_hessian_dot() {
3961        // Issue #347: the matrix-free H·v contraction must equal the dense
3962        // Hessian times v to floating tolerance, at a non-trivial β so the
3963        // softmax is away from the uniform point.
3964        let family = toy_family(7, 3, 4);
3965        let p = family.design.ncols();
3966        let m = family.active_classes();
3967        let n = family.weights.len();
3968        let design = family.design.view();
3969        // Distinct per-class β so η, and hence the Fisher block, is non-uniform.
3970        let block_states: Vec<ParameterBlockState> = (0..m)
3971            .map(|a| {
3972                let beta =
3973                    Array1::<f64>::from_shape_fn(p, |i| 0.3 * ((a + 1) as f64) - 0.1 * (i as f64));
3974                let eta = Array1::<f64>::from_shape_fn(n, |row| {
3975                    (0..p).map(|i| design[[row, i]] * beta[i]).sum()
3976                });
3977                ParameterBlockState { beta, eta }
3978            })
3979            .collect();
3980        let specs = family.build_block_specs();
3981        let ws = family
3982            .exact_newton_joint_hessian_workspace(&block_states, &specs)
3983            .expect("workspace build must succeed")
3984            .expect("workspace must be present");
3985        let dense = family
3986            .exact_newton_joint_hessian(&block_states)
3987            .expect("dense Hessian must build")
3988            .expect("dense Hessian must be present");
3989        // Several probe directions, including a unit vector per coordinate.
3990        for seed in 0..(m * p) {
3991            let v = Array1::<f64>::from_shape_fn(m * p, |i| {
3992                if i == seed {
3993                    1.0
3994                } else {
3995                    0.07 * ((i + 1) as f64).cos()
3996                }
3997            });
3998            let mf = ws
3999                .hessian_matvec(&v)
4000                .expect("matvec must succeed")
4001                .expect("matvec must be present");
4002            let dv = dense.dot(&v);
4003            for (a, b) in mf.iter().zip(dv.iter()) {
4004                assert!(
4005                    (a - b).abs() < 1.0e-9,
4006                    "matrix-free matvec {a} != dense {b}"
4007                );
4008            }
4009            // hessian_matvec_into must agree with the owned form.
4010            let mut into = Array1::<f64>::from_elem(m * p, f64::NAN);
4011            let wrote = ws
4012                .hessian_matvec_into(&v, &mut into)
4013                .expect("matvec_into must succeed");
4014            assert!(wrote, "matvec_into must report it wrote");
4015            for (a, b) in into.iter().zip(mf.iter()) {
4016                assert!((a - b).abs() < 1.0e-12, "matvec_into {a} != matvec {b}");
4017            }
4018        }
4019        // Diagonal must equal the dense diagonal.
4020        let mf_diag = ws
4021            .hessian_diagonal()
4022            .expect("diagonal must succeed")
4023            .expect("diagonal must be present");
4024        let dense_diag = dense.diag();
4025        for (a, b) in mf_diag.iter().zip(dense_diag.iter()) {
4026            assert!((a - b).abs() < 1.0e-9, "matrix-free diag {a} != dense {b}");
4027        }
4028    }
4029
4030    #[test]
4031    fn batched_second_directional_all_axes_matches_per_axis() {
4032        // The #1082 fix: `assemble_all_axis_second_directional_derivatives`
4033        // (one Gram-assembly pass for all p axes) must equal the per-axis route
4034        // `exact_newton_joint_hessiansecond_directional_derivative(e_a)` the
4035        // generic trait default loops, axis-by-axis, to bit-tight tolerance.
4036        let family = toy_family(9, 3, 4);
4037        let p = family.design.ncols();
4038        let m = family.active_classes();
4039        let n = family.weights.len();
4040        let design = family.design.view();
4041        let block_states: Vec<ParameterBlockState> = (0..m)
4042            .map(|a| {
4043                let beta = Array1::<f64>::from_shape_fn(p, |i| {
4044                    0.25 * ((a + 1) as f64) - 0.13 * (i as f64)
4045                });
4046                let eta = Array1::<f64>::from_shape_fn(n, |row| {
4047                    (0..p).map(|i| design[[row, i]] * beta[i]).sum()
4048                });
4049                ParameterBlockState { beta, eta }
4050            })
4051            .collect();
4052        let specs = family.build_block_specs();
4053        let dim = m * p;
4054
4055        // A non-trivial first direction δ (not a canonical axis).
4056        let delta = Array1::<f64>::from_shape_fn(dim, |i| {
4057            0.4 - 0.07 * (i as f64) + 0.03 * ((i * i) as f64).cos()
4058        });
4059
4060        // Batched: all axes in one pass.
4061        let batched = family
4062            .joint_jeffreys_information_second_directional_all_axes_with_specs(
4063                &block_states,
4064                &specs,
4065                &delta,
4066            )
4067            .expect("batched second-directional must succeed")
4068            .expect("batched second-directional must be present");
4069        assert_eq!(batched.len(), dim, "one matrix per canonical axis");
4070
4071        // Per-axis reference: the route the generic trait default takes.
4072        for axis in 0..dim {
4073            let mut e_a = Array1::<f64>::zeros(dim);
4074            e_a[axis] = 1.0;
4075            let per_axis = family
4076                .exact_newton_joint_hessiansecond_directional_derivative(
4077                    &block_states,
4078                    &delta,
4079                    &e_a,
4080                )
4081                .expect("per-axis second-directional must succeed")
4082                .expect("per-axis second-directional must be present");
4083            assert_eq!(batched[axis].dim(), (dim, dim));
4084            for r in 0..dim {
4085                for c in 0..dim {
4086                    let a = batched[axis][[r, c]];
4087                    let b = per_axis[[r, c]];
4088                    assert!(
4089                        (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
4090                        "axis {axis} entry ({r},{c}): batched {a} != per-axis {b}"
4091                    );
4092                }
4093            }
4094        }
4095    }
4096
4097    #[test]
4098    fn batched_general_directional_derivatives_match_per_direction() {
4099        // The penguin #1082 timeout spends each exact outer-gradient eval
4100        // rebuilding `D_beta H[delta_j]` for many non-canonical mode-response
4101        // directions. The workspace batch must preserve the old per-direction
4102        // arithmetic while sharing the row/probability sweep.
4103        let family = toy_family(11, 4, 3);
4104        let p = family.design.ncols();
4105        let m = family.active_classes();
4106        let n = family.weights.len();
4107        let dim = m * p;
4108        let design = family.design.view();
4109        let block_states: Vec<ParameterBlockState> = (0..m)
4110            .map(|a| {
4111                let beta = Array1::<f64>::from_shape_fn(p, |i| {
4112                    0.18 * ((a + 2) as f64) + 0.09 * ((i + 1) as f64).sin()
4113                });
4114                let eta = Array1::<f64>::from_shape_fn(n, |row| {
4115                    (0..p).map(|i| design[[row, i]] * beta[i]).sum()
4116                });
4117                ParameterBlockState { beta, eta }
4118            })
4119            .collect();
4120        let eta = family
4121            .collect_eta_matrix(&block_states)
4122            .expect("eta collection must succeed");
4123        let directions: Vec<Array1<f64>> = (0..5)
4124            .map(|seed| {
4125                Array1::<f64>::from_shape_fn(dim, |idx| {
4126                    0.31 * ((seed + 1 + idx) as f64).sin()
4127                        - 0.07 * ((seed * 3 + idx + 2) as f64).cos()
4128                })
4129            })
4130            .collect();
4131
4132        let batched = family
4133            .assemble_directional_derivatives(eta.view(), &directions)
4134            .expect("batched first directional derivatives must succeed");
4135        assert_eq!(batched.len(), directions.len());
4136        for (dir_idx, direction) in directions.iter().enumerate() {
4137            let per_direction = family
4138                .exact_newton_joint_hessian_directional_derivative(&block_states, direction)
4139                .expect("per-direction derivative must succeed")
4140                .expect("per-direction derivative must be present");
4141            for r in 0..dim {
4142                for c in 0..dim {
4143                    let a = batched[dir_idx][[r, c]];
4144                    let b = per_direction[[r, c]];
4145                    assert!(
4146                        (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
4147                        "direction {dir_idx} entry ({r},{c}): batched {a} != per-direction {b}"
4148                    );
4149                }
4150            }
4151        }
4152
4153        let specs = family.build_block_specs();
4154        let workspace = family
4155            .exact_newton_joint_hessian_workspace(&block_states, &specs)
4156            .expect("workspace build must succeed")
4157            .expect("workspace must be present");
4158        let operators = workspace
4159            .directional_derivative_operators(&directions)
4160            .expect("workspace batched operators must succeed");
4161        assert_eq!(operators.len(), directions.len());
4162        for (dir_idx, maybe_operator) in operators.into_iter().enumerate() {
4163            let dense = maybe_operator
4164                .expect("workspace must return a derivative operator")
4165                .to_dense();
4166            for r in 0..dim {
4167                for c in 0..dim {
4168                    let a = dense[[r, c]];
4169                    let b = batched[dir_idx][[r, c]];
4170                    assert!(
4171                        (a - b).abs() <= 1e-12 * (1.0 + b.abs()),
4172                        "operator direction {dir_idx} entry ({r},{c}): {a} != {b}"
4173                    );
4174                }
4175            }
4176        }
4177    }
4178
4179    #[test]
4180    fn workspace_batched_second_directional_pairs_match_per_pair() {
4181        // The exact outer Hessian sends arbitrary mode-response pairs through
4182        // `second_directional_derivative_operators`. This is the #1082 penguin
4183        // hot path: all pair corrections must be fused without changing the
4184        // old per-pair second-directional operator values.
4185        let family = toy_family(10, 4, 4);
4186        let p = family.design.ncols();
4187        let m = family.active_classes();
4188        let n = family.weights.len();
4189        let dim = m * p;
4190        let design = family.design.view();
4191        let block_states: Vec<ParameterBlockState> = (0..m)
4192            .map(|a| {
4193                let beta = Array1::<f64>::from_shape_fn(p, |i| {
4194                    0.11 * ((a + 3) as f64) - 0.06 * ((i + 2) as f64).cos()
4195                });
4196                let eta = Array1::<f64>::from_shape_fn(n, |row| {
4197                    (0..p).map(|i| design[[row, i]] * beta[i]).sum()
4198                });
4199                ParameterBlockState { beta, eta }
4200            })
4201            .collect();
4202        let specs = family.build_block_specs();
4203        let workspace = family
4204            .exact_newton_joint_hessian_workspace(&block_states, &specs)
4205            .expect("workspace build must succeed")
4206            .expect("workspace must be present");
4207        let pairs: Vec<(Array1<f64>, Array1<f64>)> = (0..7)
4208            .map(|seed| {
4209                let u = Array1::<f64>::from_shape_fn(dim, |idx| {
4210                    0.19 * ((seed + idx + 1) as f64).sin()
4211                        + 0.05 * ((2 * seed + idx + 3) as f64).cos()
4212                });
4213                let v = Array1::<f64>::from_shape_fn(dim, |idx| {
4214                    -0.17 * ((seed + 2 * idx + 5) as f64).cos()
4215                        + 0.04 * ((seed + idx + 7) as f64).sin()
4216                });
4217                (u, v)
4218            })
4219            .collect();
4220
4221        let batched = workspace
4222            .second_directional_derivative_operators(&pairs)
4223            .expect("workspace batched second-directional operators must succeed");
4224        assert_eq!(batched.len(), pairs.len());
4225
4226        for (pair_idx, ((u, v), maybe_operator)) in
4227            pairs.iter().zip(batched.into_iter()).enumerate()
4228        {
4229            let dense = maybe_operator
4230                .expect("workspace must return a second-directional operator")
4231                .to_dense();
4232            let per_pair = family
4233                .exact_newton_joint_hessiansecond_directional_derivative(&block_states, u, v)
4234                .expect("per-pair second-directional must succeed")
4235                .expect("per-pair second-directional must be present");
4236            for r in 0..dim {
4237                for c in 0..dim {
4238                    let a = dense[[r, c]];
4239                    let b = per_pair[[r, c]];
4240                    assert!(
4241                        (a - b).abs() <= 1e-10 * (1.0 + b.abs()),
4242                        "pair {pair_idx} entry ({r},{c}): batched {a} != per-pair {b}"
4243                    );
4244                }
4245            }
4246        }
4247    }
4248
4249    /// Issue #932 ORACLE: the matrix-free directional / second-directional
4250    /// joint-Hessian operator must reproduce the dense
4251    /// `DenseMatrixHyperOperator` path to ≤1e-10 on every consumed surface —
4252    /// the full projected matrix `Fᵀ B F`, its trace, the matvec `B·v`, and the
4253    /// dense materialization `B`. This pins the #932 cutover's strict
4254    /// outer-Hessian parity contract: the matrix-free operator is now the sole
4255    /// production path, so this oracle (and the existing batched-operator tests
4256    /// that exercise `to_dense`) are the regression guard against any drift.
4257    #[test]
4258    fn matrix_free_directional_operator_matches_dense_oracle() {
4259        // A few representative small fits (the operator path fires for small
4260        // `total_rho_dim`): vary N, P, K and the projection rank.
4261        for &(n, p, k, rank) in &[(11, 4, 3, 2), (9, 5, 4, 3), (13, 3, 5, 4), (7, 6, 3, 1)] {
4262            let family = toy_family(n, p, k);
4263            let m = family.active_classes();
4264            let dim = m * p;
4265            let design = family.design.view();
4266            let block_states: Vec<ParameterBlockState> = (0..m)
4267                .map(|a| {
4268                    let beta = Array1::<f64>::from_shape_fn(p, |i| {
4269                        0.13 * ((a + 2) as f64) - 0.08 * ((i + 1) as f64).cos()
4270                    });
4271                    let eta = Array1::<f64>::from_shape_fn(n, |row| {
4272                        (0..p).map(|i| design[[row, i]] * beta[i]).sum()
4273                    });
4274                    ParameterBlockState { beta, eta }
4275                })
4276                .collect();
4277            let eta = family
4278                .collect_eta_matrix(&block_states)
4279                .expect("eta collection must succeed");
4280            let probs = family.row_probabilities(eta.view());
4281
4282            // Representative dense factor F (dim × rank) and a probe vector.
4283            let factor = Array2::<f64>::from_shape_fn((dim, rank), |(r, c)| {
4284                0.41 * ((r + 2 * c + 1) as f64).sin() - 0.12 * ((3 * r + c + 2) as f64).cos()
4285            });
4286            let probe = Array1::<f64>::from_shape_fn(dim, |idx| {
4287                0.27 * ((idx + 1) as f64).sin() + 0.05 * ((idx + 3) as f64).cos()
4288            });
4289
4290            let directions: Vec<Array1<f64>> = (0..4)
4291                .map(|seed| {
4292                    Array1::<f64>::from_shape_fn(dim, |idx| {
4293                        0.29 * ((seed + idx + 1) as f64).sin()
4294                            - 0.06 * ((2 * seed + idx + 2) as f64).cos()
4295                    })
4296                })
4297                .collect();
4298
4299            // First-directional: dense vs matrix-free.
4300            let dense_mats = family
4301                .assemble_directional_derivatives_from_probs(probs.view(), &directions)
4302                .expect("dense directional assembly must succeed");
4303            for (idx, direction) in directions.iter().enumerate() {
4304                let dense = DenseMatrixHyperOperator {
4305                    matrix: dense_mats[idx].clone(),
4306                };
4307                let mf = family
4308                    .directional_hyper_operator(probs.view(), direction)
4309                    .expect("matrix-free directional operator must build");
4310                assert_oracle_parity(
4311                    &dense,
4312                    &mf,
4313                    &factor,
4314                    &probe,
4315                    &format!("dir {idx} n={n} p={p} k={k}"),
4316                );
4317            }
4318
4319            // Second-directional: dense vs matrix-free.
4320            let pairs: Vec<(Array1<f64>, Array1<f64>)> = (0..3)
4321                .map(|seed| {
4322                    let u = Array1::<f64>::from_shape_fn(dim, |idx| {
4323                        0.21 * ((seed + idx + 1) as f64).sin()
4324                    });
4325                    let v = Array1::<f64>::from_shape_fn(dim, |idx| {
4326                        -0.18 * ((seed + 2 * idx + 4) as f64).cos()
4327                    });
4328                    (u, v)
4329                })
4330                .collect();
4331            let dense_pairs = family
4332                .assemble_second_directional_derivatives_from_probs(probs.view(), &pairs)
4333                .expect("dense second-directional assembly must succeed");
4334            for (idx, (u, v)) in pairs.iter().enumerate() {
4335                let dense = DenseMatrixHyperOperator {
4336                    matrix: dense_pairs[idx].clone(),
4337                };
4338                let mf = family
4339                    .second_directional_hyper_operator(probs.view(), u, v)
4340                    .expect("matrix-free second-directional operator must build");
4341                assert_oracle_parity(
4342                    &dense,
4343                    &mf,
4344                    &factor,
4345                    &probe,
4346                    &format!("pair {idx} n={n} p={p} k={k}"),
4347                );
4348            }
4349        }
4350    }
4351
4352    /// Assert dense-vs-matrix-free parity on every consumed surface to ≤1e-10.
4353    fn assert_oracle_parity(
4354        dense: &DenseMatrixHyperOperator,
4355        mf: &MultinomialDirectionalHyperOperator,
4356        factor: &Array2<f64>,
4357        probe: &Array1<f64>,
4358        ctx: &str,
4359    ) {
4360        assert_eq!(dense.dim(), mf.dim(), "{ctx}: dim mismatch");
4361
4362        // Full projected matrix Fᵀ B F — the surface the consumer needs in full.
4363        let pd = dense.projected_matrix(factor);
4364        let pm = mf.projected_matrix(factor);
4365        for ((r, c), &a) in pd.indexed_iter() {
4366            let b = pm[[r, c]];
4367            assert!(
4368                (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
4369                "{ctx}: projected_matrix[{r},{c}] dense {a} != matrix-free {b}"
4370            );
4371        }
4372
4373        // Trace of the projection.
4374        let td = dense.trace_projected_factor(factor);
4375        let tm = mf.trace_projected_factor(factor);
4376        assert!(
4377            (td - tm).abs() <= 1e-10 * (1.0 + td.abs()),
4378            "{ctx}: trace dense {td} != matrix-free {tm}"
4379        );
4380
4381        // Matvec B·v.
4382        let bvd = dense.mul_vec(probe);
4383        let bvm = mf.mul_vec(probe);
4384        for (idx, (&a, &b)) in bvd.iter().zip(bvm.iter()).enumerate() {
4385            assert!(
4386                (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
4387                "{ctx}: mul_vec[{idx}] dense {a} != matrix-free {b}"
4388            );
4389        }
4390
4391        // Dense materialization B.
4392        let dd = dense.to_dense();
4393        let dm = mf.to_dense();
4394        for ((r, c), &a) in dd.indexed_iter() {
4395            let b = dm[[r, c]];
4396            assert!(
4397                (a - b).abs() <= 1e-10 * (1.0 + a.abs()),
4398                "{ctx}: to_dense[{r},{c}] dense {a} != matrix-free {b}"
4399            );
4400        }
4401    }
4402
4403    #[test]
4404    fn new_rejects_k_less_than_two() {
4405        let n = 3;
4406        let y = array![[1.0], [1.0], [1.0]];
4407        let w = Array1::<f64>::ones(n);
4408        let x = Arc::new(Array2::<f64>::ones((n, 1)));
4409        let zero = Array2::<f64>::zeros((1, 1));
4410        let s = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(zero)]);
4411        let err = MultinomialFamily::new(y, w, 1, x, s).expect_err("K = 1 must be rejected");
4412        assert!(err.contains("K"));
4413    }
4414
4415    // ----------------------------------------------------------------------
4416    // Matrix-free joint-Hessian matvec (#347).
4417    //
4418    // The contract: `MultinomialHessianWorkspace::hessian_matvec` /
4419    // `hessian_matvec_into` / `hessian_diagonal` must agree with the dense
4420    // joint Hessian `H = block(X^T W(β) X)` that the workspace also exposes
4421    // through `hessian_dense`, while never materialising the dense matrix on
4422    // the matvec path. The tests below pin three independent angles:
4423    //   1. matvec == dense·v across many directions and a non-trivial β;
4424    //   2. diagonal == dense diagonal bit-for-bit;
4425    //   3. matvec == central finite difference of the −logL gradient, an
4426    //      angle that never touches the Fisher-block assembly at all.
4427    // ----------------------------------------------------------------------
4428
4429    /// Build a `MultinomialFamily` with explicit row weights and a smooth
4430    /// deterministic design / one-hot response so tests are reproducible.
4431    fn family_with_weights(
4432        n_obs: usize,
4433        p: usize,
4434        k: usize,
4435        weights: Array1<f64>,
4436    ) -> MultinomialFamily {
4437        let y = {
4438            let mut y = Array2::<f64>::zeros((n_obs, k));
4439            for i in 0..n_obs {
4440                y[[i, (3 * i + 1) % k]] = 1.0;
4441            }
4442            y
4443        };
4444        let design = Arc::new(Array2::<f64>::from_shape_fn((n_obs, p), |(i, j)| {
4445            0.7 * ((i as f64 + 1.0) * 0.31 + (j as f64) * 0.53).sin() - 0.2 * (j as f64)
4446        }));
4447        let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(
4448            Array2::<f64>::from_shape_fn((p, p), |(i, j)| if i == j { 1.0 } else { 0.0 }),
4449        )]);
4450        MultinomialFamily::new(y, weights, k, design, penalties)
4451            .expect("family_with_weights must construct")
4452    }
4453
4454    /// Stacked block states whose per-class η is `X·β_a`, matching the
4455    /// converged-state contract the workspace consumes.
4456    fn states_at_betas(
4457        family: &MultinomialFamily,
4458        betas: &[Array1<f64>],
4459    ) -> Vec<ParameterBlockState> {
4460        let x = family.design.view();
4461        betas
4462            .iter()
4463            .map(|b| ParameterBlockState {
4464                beta: b.clone(),
4465                eta: x.dot(b),
4466            })
4467            .collect()
4468    }
4469
4470    /// Deterministic, non-trivial per-class coefficient vectors.
4471    fn sample_betas(m: usize, p: usize, scale: f64) -> Vec<Array1<f64>> {
4472        (0..m)
4473            .map(|a| {
4474                Array1::from_shape_fn(p, |i| {
4475                    scale * (0.41 * (a as f64 + 1.0) - 0.23 * (i as f64) + 0.13).sin()
4476                })
4477            })
4478            .collect()
4479    }
4480
4481    /// Stacked −logL gradient `g_{a·P+i} = Σ_n X_{n,i} w_n (p_{n,a} − y_{n,a})`,
4482    /// computed straight from the softmax probabilities — no Fisher block, no
4483    /// `dense_block_xtwx`. Used as the independent finite-difference oracle.
4484    fn neglogl_grad(family: &MultinomialFamily, states: &[ParameterBlockState]) -> Array1<f64> {
4485        let eta = family.collect_eta_matrix(states).expect("eta collect");
4486        let probs = family.row_probabilities(eta.view());
4487        let x = family.design.view();
4488        let n = family.weights.len();
4489        let p = family.design.ncols();
4490        let m = family.active_classes();
4491        let mut g = Array1::<f64>::zeros(m * p);
4492        for a in 0..m {
4493            for i in 0..p {
4494                let mut acc = 0.0_f64;
4495                for row in 0..n {
4496                    acc += x[[row, i]]
4497                        * family.weights[row]
4498                        * (probs[[row, a]] - family.y_one_hot[[row, a]]);
4499                }
4500                g[a * p + i] = acc;
4501            }
4502        }
4503        g
4504    }
4505
4506    fn perturb(betas: &[Array1<f64>], v: &Array1<f64>, factor: f64) -> Vec<Array1<f64>> {
4507        let p = betas[0].len();
4508        betas
4509            .iter()
4510            .enumerate()
4511            .map(|(a, b)| Array1::from_shape_fn(p, |i| b[i] + factor * v[a * p + i]))
4512            .collect()
4513    }
4514
4515    #[test]
4516    fn matrix_free_matvec_matches_dense_across_directions() {
4517        // K = 4 ⇒ M = 3 active classes with genuine off-diagonal coupling.
4518        let n = 13;
4519        let p = 4;
4520        let k = 4;
4521        let family = family_with_weights(
4522            n,
4523            p,
4524            k,
4525            Array1::from_shape_fn(n, |i| 0.5 + 0.5 * ((i as f64) * 0.37).cos().abs()),
4526        );
4527        let m = family.active_classes();
4528        let total = m * p;
4529        let states = states_at_betas(&family, &sample_betas(m, p, 0.8));
4530        let specs = family.build_block_specs();
4531        let ws = family
4532            .exact_newton_joint_hessian_workspace(&states, &specs)
4533            .expect("workspace build")
4534            .expect("workspace present");
4535        let dense = ws.hessian_dense().expect("dense").expect("dense present");
4536
4537        for seed in 0..8usize {
4538            let v = Array1::from_shape_fn(total, |idx| {
4539                ((seed * 31 + idx * 17 + 5) as f64 * 0.123).cos()
4540            });
4541            let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4542            let dv = dense.dot(&v);
4543            let mut max_abs = 0.0_f64;
4544            let mut scale = 1.0e-300_f64;
4545            for idx in 0..total {
4546                max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
4547                scale = scale.max(dv[idx].abs());
4548            }
4549            assert!(
4550                max_abs <= 1.0e-10 * scale + 1.0e-13,
4551                "seed {seed}: matrix-free matvec deviates from dense by {max_abs} (scale {scale})"
4552            );
4553        }
4554    }
4555
4556    #[test]
4557    fn matrix_free_matvec_does_not_allocate_dense_but_matches_at_extreme_eta() {
4558        // Large |η| drives the softmax to near-degenerate probabilities
4559        // (some p ≈ 1, the rest ≈ 0). The matvec must stay finite and still
4560        // track the dense reference within tight tolerance.
4561        let n = 9;
4562        let p = 3;
4563        let k = 5;
4564        let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
4565        let m = family.active_classes();
4566        let total = m * p;
4567        let states = states_at_betas(&family, &sample_betas(m, p, 12.0));
4568        let specs = family.build_block_specs();
4569        let ws = family
4570            .exact_newton_joint_hessian_workspace(&states, &specs)
4571            .expect("workspace build")
4572            .expect("workspace present");
4573        let dense = ws.hessian_dense().expect("dense").expect("dense present");
4574        let v = Array1::from_shape_fn(total, |idx| ((idx as f64) * 0.91 - 1.0).sin());
4575        let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4576        let dv = dense.dot(&v);
4577        let mut max_abs = 0.0_f64;
4578        let mut scale = 1.0e-300_f64;
4579        for idx in 0..total {
4580            assert!(mf[idx].is_finite(), "matvec entry {idx} not finite");
4581            max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
4582            scale = scale.max(dv[idx].abs());
4583        }
4584        assert!(
4585            max_abs <= 1.0e-10 * scale + 1.0e-13,
4586            "extreme-η matvec deviates from dense by {max_abs} (scale {scale})"
4587        );
4588    }
4589
4590    #[test]
4591    fn matrix_free_matvec_handles_zero_weight_rows() {
4592        // Zero-weight rows must drop out of both paths identically.
4593        let n = 10;
4594        let p = 3;
4595        let k = 3;
4596        let mut w = Array1::<f64>::ones(n);
4597        w[2] = 0.0;
4598        w[5] = 0.0;
4599        w[9] = 0.0;
4600        let family = family_with_weights(n, p, k, w);
4601        let m = family.active_classes();
4602        let total = m * p;
4603        let states = states_at_betas(&family, &sample_betas(m, p, 0.6));
4604        let specs = family.build_block_specs();
4605        let ws = family
4606            .exact_newton_joint_hessian_workspace(&states, &specs)
4607            .expect("workspace build")
4608            .expect("workspace present");
4609        let dense = ws.hessian_dense().expect("dense").expect("dense present");
4610        let v = Array1::from_shape_fn(total, |idx| (idx as f64 + 0.5).cos());
4611        let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4612        let dv = dense.dot(&v);
4613        let mut max_abs = 0.0_f64;
4614        let mut scale = 1.0e-300_f64;
4615        for idx in 0..total {
4616            max_abs = max_abs.max((mf[idx] - dv[idx]).abs());
4617            scale = scale.max(dv[idx].abs());
4618        }
4619        assert!(
4620            max_abs <= 1.0e-10 * scale + 1.0e-13,
4621            "zero-weight matvec deviates from dense by {max_abs} (scale {scale})"
4622        );
4623    }
4624
4625    #[test]
4626    fn workspace_gradient_and_loglik_match_family_evaluation_and_prefer_operator() {
4627        // The frozen-β workspace must serve the joint log-likelihood and the
4628        // stacked −logL gradient from its cached probabilities, bit-consistent
4629        // with the family's `exact_newton_joint_gradient_evaluation`, and it
4630        // must declare the Operator source preference so the inner joint-Newton
4631        // routes through the matrix-free H·v contraction instead of assembling
4632        // and factorizing the dense (K−1)P×(K−1)P Hessian every cycle
4633        // (#714 / #722 inner cost).
4634        let n = 11;
4635        let p = 4;
4636        let k = 3;
4637        let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
4638        let m = family.active_classes();
4639        let states = states_at_betas(&family, &sample_betas(m, p, 0.9));
4640        let specs = family.build_block_specs();
4641
4642        let family_eval = family
4643            .exact_newton_joint_gradient_evaluation(&states, &specs)
4644            .expect("family joint gradient eval")
4645            .expect("family joint gradient present");
4646
4647        let ws = family
4648            .exact_newton_joint_hessian_workspace(&states, &specs)
4649            .expect("workspace build")
4650            .expect("workspace present");
4651
4652        assert_eq!(
4653            ws.hessian_source_preference(),
4654            JointHessianSourcePreference::Operator,
4655            "multinomial workspace must prefer the operator (matrix-free) source"
4656        );
4657
4658        let ws_loglik = ws
4659            .joint_log_likelihood_evaluation()
4660            .expect("workspace loglik")
4661            .expect("workspace loglik present");
4662        assert!(
4663            (ws_loglik - family_eval.log_likelihood).abs()
4664                <= 1e-12 * (1.0 + family_eval.log_likelihood.abs()),
4665            "workspace loglik {ws_loglik} != family loglik {}",
4666            family_eval.log_likelihood
4667        );
4668
4669        let ws_grad_eval = ws
4670            .joint_gradient_evaluation()
4671            .expect("workspace gradient eval")
4672            .expect("workspace gradient present");
4673        assert!(
4674            (ws_grad_eval.log_likelihood - family_eval.log_likelihood).abs()
4675                <= 1e-12 * (1.0 + family_eval.log_likelihood.abs()),
4676            "workspace gradient-eval loglik mismatch"
4677        );
4678        assert_eq!(ws_grad_eval.gradient.len(), family_eval.gradient.len());
4679        let mut max_abs = 0.0_f64;
4680        let mut scale = 1.0e-300_f64;
4681        for idx in 0..family_eval.gradient.len() {
4682            max_abs = max_abs.max((ws_grad_eval.gradient[idx] - family_eval.gradient[idx]).abs());
4683            scale = scale.max(family_eval.gradient[idx].abs());
4684        }
4685        assert!(
4686            max_abs <= 1e-10 * scale + 1e-13,
4687            "workspace gradient deviates from family gradient by {max_abs} (scale {scale})"
4688        );
4689    }
4690
4691    #[test]
4692    fn matrix_free_matvec_binary_k_equals_two() {
4693        // K = 2 ⇒ M = 1: no off-diagonal block, H·v reduces to the scalar
4694        // logistic curvature. Guards the degenerate single-active-class arm.
4695        let n = 7;
4696        let p = 3;
4697        let k = 2;
4698        let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
4699        let m = family.active_classes();
4700        assert_eq!(m, 1);
4701        let total = m * p;
4702        let states = states_at_betas(&family, &sample_betas(m, p, 1.1));
4703        let specs = family.build_block_specs();
4704        let ws = family
4705            .exact_newton_joint_hessian_workspace(&states, &specs)
4706            .expect("workspace build")
4707            .expect("workspace present");
4708        let dense = ws.hessian_dense().expect("dense").expect("dense present");
4709        let v = Array1::from_shape_fn(total, |idx| (idx as f64 * 0.7 + 0.2).sin());
4710        let mf = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4711        let dv = dense.dot(&v);
4712        for idx in 0..total {
4713            assert!(
4714                (mf[idx] - dv[idx]).abs() <= 1.0e-12 * (1.0 + dv[idx].abs()),
4715                "binary matvec entry {idx}: {} vs {}",
4716                mf[idx],
4717                dv[idx]
4718            );
4719        }
4720    }
4721
4722    #[test]
4723    fn matrix_free_matvec_into_matches_owned_return() {
4724        let n = 8;
4725        let p = 3;
4726        let k = 4;
4727        let family = family_with_weights(n, p, k, Array1::<f64>::ones(n));
4728        let m = family.active_classes();
4729        let total = m * p;
4730        let states = states_at_betas(&family, &sample_betas(m, p, 0.9));
4731        let specs = family.build_block_specs();
4732        let ws = family
4733            .exact_newton_joint_hessian_workspace(&states, &specs)
4734            .expect("workspace build")
4735            .expect("workspace present");
4736        let v = Array1::from_shape_fn(total, |idx| (idx as f64 * 1.7 - 0.3).cos());
4737        let owned = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4738        // Pre-fill `out` with garbage to prove the into-variant overwrites it.
4739        let mut out = Array1::from_elem(total, 7.0_f64);
4740        let wrote = ws.hessian_matvec_into(&v, &mut out).expect("matvec_into");
4741        assert!(wrote, "matvec_into must report it wrote a result");
4742        assert_eq!(out, owned, "into-variant must match owned return bitwise");
4743    }
4744
4745    #[test]
4746    fn matrix_free_diagonal_is_bit_identical_to_dense_diag() {
4747        let n = 11;
4748        let p = 4;
4749        let k = 4;
4750        let family = family_with_weights(
4751            n,
4752            p,
4753            k,
4754            Array1::from_shape_fn(n, |i| 0.25 + (i as f64 % 3.0)),
4755        );
4756        let m = family.active_classes();
4757        let total = m * p;
4758        let states = states_at_betas(&family, &sample_betas(m, p, 0.7));
4759        let specs = family.build_block_specs();
4760        let ws = family
4761            .exact_newton_joint_hessian_workspace(&states, &specs)
4762            .expect("workspace build")
4763            .expect("workspace present");
4764        let dense = ws.hessian_dense().expect("dense").expect("dense present");
4765        let diag = ws
4766            .hessian_diagonal()
4767            .expect("diagonal")
4768            .expect("diagonal some");
4769        for idx in 0..total {
4770            // The matrix-free diagonal (`hessian_diagonal`) accumulates
4771            // Σ_row w·p_a(1-p_a)·x_i² directly per coefficient, while the dense
4772            // path builds the full XᵀWX Gram via a different (blocked)
4773            // accumulation order. The two are algebraically identical but the
4774            // distinct summation orders differ in the last ULP, so exact
4775            // bit-for-bit equality is unachievable; assert agreement to a few
4776            // ULP via a relative tolerance instead (gam#846).
4777            let got = diag[idx];
4778            let expected = dense[[idx, idx]];
4779            let tol = 1e-12 * (1.0 + expected.abs());
4780            assert!(
4781                (got - expected).abs() <= tol,
4782                "matrix-free diagonal entry {idx} must equal dense diagonal to a few ULP: \
4783                 got={got} dense={expected} (tol={tol})"
4784            );
4785        }
4786    }
4787
4788    #[test]
4789    fn matrix_free_matvec_matches_gradient_finite_difference() {
4790        // Independent oracle: H = ∂(−logL gradient)/∂β under the canonical
4791        // logit link, so H·v equals the central difference of the −logL
4792        // gradient along v. This path uses only softmax probabilities and
4793        // never calls the Fisher-block assembly the matvec shares with dense.
4794        let n = 12;
4795        let p = 3;
4796        let k = 4;
4797        let family = family_with_weights(
4798            n,
4799            p,
4800            k,
4801            Array1::from_shape_fn(n, |i| 0.4 + 0.3 * ((i as f64) * 0.6).sin().abs()),
4802        );
4803        let m = family.active_classes();
4804        let total = m * p;
4805        let betas = sample_betas(m, p, 0.5);
4806        let states = states_at_betas(&family, &betas);
4807        let specs = family.build_block_specs();
4808        let ws = family
4809            .exact_newton_joint_hessian_workspace(&states, &specs)
4810            .expect("workspace build")
4811            .expect("workspace present");
4812
4813        let v = Array1::from_shape_fn(total, |idx| 0.5 * ((idx as f64 * 1.3 + 0.7).sin()));
4814        let hv = ws.hessian_matvec(&v).expect("matvec").expect("matvec some");
4815
4816        let eps = 1.0e-6;
4817        let g_plus = neglogl_grad(
4818            &family,
4819            &states_at_betas(&family, &perturb(&betas, &v, eps)),
4820        );
4821        let g_minus = neglogl_grad(
4822            &family,
4823            &states_at_betas(&family, &perturb(&betas, &v, -eps)),
4824        );
4825        let mut max_abs = 0.0_f64;
4826        let mut scale = 1.0e-300_f64;
4827        for idx in 0..total {
4828            let fd = (g_plus[idx] - g_minus[idx]) / (2.0 * eps);
4829            max_abs = max_abs.max((hv[idx] - fd).abs());
4830            scale = scale.max(fd.abs());
4831        }
4832        assert!(
4833            max_abs <= 1.0e-5 * scale + 1.0e-7,
4834            "matvec vs gradient finite-difference deviates by {max_abs} (scale {scale})"
4835        );
4836    }
4837
4838    // ----------------------------------------------------------------------
4839    // #932 doctrine oracle for the softmax directional / second-directional
4840    // joint-Hessian assembly.
4841    //
4842    // The production generated path builds the per-canonical-axis derivatives of the
4843    // joint softmax Fisher Hessian `H(β) = block(Xᵀ W(β) X)`,
4844    // `W = diag(p) − p pᵀ`, in one fused row sweep
4845    // (`assemble_all_axis_directional_derivatives`,
4846    // `assemble_all_axis_second_directional_derivatives`). Their
4847    // `diag(p)−ppᵀ` coefficients come from the same normalized-softmax
4848    // perturbation expression as the general-direction path. This independent
4849    // finite-difference oracle catches a dropped or mis-weighted coefficient
4850    // (the #736/#947 bug genus), not divergence between production formulas.
4851    //
4852    // MECHANICAL SOURCE (independent of the assembly under test):
4853    //  * `H(β) = exact_newton_joint_hessian(β)` is the STATIC joint Fisher
4854    //    Hessian — the assembly's own zeroth order. Its derivative along the
4855    //    canonical axis `e_{(a0,i0)}` is `∂H/∂β_{a0,i0}`, which we take by a
4856    //    central finite difference of `H` (a quantity that never calls the
4857    //    directional assembly). This pins the FIRST-directional set.
4858    //  * `Hdot[δ](β) = exact_newton_joint_hessian_directional_derivative(β, δ)`
4859    //    via the per-direction `directional_fisher_jet` → `dense_block_xtwx`
4860    //    route (the GENERAL-direction branch, NOT the canonical-axis memo). Its
4861    //    derivative along canonical axis `e_a` is `∂Hdot[δ]/∂β_a`, taken by a
4862    //    central FD of `Hdot[δ]`. This pins the SECOND-directional set against a
4863    //    different assembly than the one under test.
4864    // ----------------------------------------------------------------------
4865
4866    /// Perturb a stacked β set by `factor·X·e_{(a0,i0)}` in the η domain: add
4867    /// `factor` to coefficient `i0` of class `a0` and rebuild the η states.
4868    fn perturb_axis(
4869        family: &MultinomialFamily,
4870        betas: &[Array1<f64>],
4871        a0: usize,
4872        i0: usize,
4873        factor: f64,
4874    ) -> Vec<ParameterBlockState> {
4875        let mut shifted = betas.to_vec();
4876        shifted[a0][i0] += factor;
4877        states_at_betas(family, &shifted)
4878    }
4879
4880    #[test]
4881    fn all_axis_directional_derivatives_match_static_hessian_finite_difference() {
4882        // K = 4 ⇒ M = 3 active classes with genuine off-diagonal softmax
4883        // coupling; p = 3 coefficients per class.
4884        let n = 11;
4885        let p = 3;
4886        let k = 4;
4887        let family = family_with_weights(
4888            n,
4889            p,
4890            k,
4891            Array1::from_shape_fn(n, |i| 0.5 + 0.4 * ((i as f64) * 0.41).sin().abs()),
4892        );
4893        let m = family.active_classes();
4894        let total = m * p;
4895        let betas = sample_betas(m, p, 0.6);
4896        let states = states_at_betas(&family, &betas);
4897        let eta = family.collect_eta_matrix(&states).expect("eta collect");
4898
4899        let hand = family.assemble_all_axis_directional_derivatives(eta.view());
4900        assert_eq!(
4901            hand.len(),
4902            total,
4903            "one directional matrix per canonical axis"
4904        );
4905
4906        let eps = 1.0e-6;
4907        let mut max_rel = 0.0_f64;
4908        for a0 in 0..m {
4909            for i0 in 0..p {
4910                let axis = a0 * p + i0;
4911                let h_plus = family
4912                    .exact_newton_joint_hessian(&perturb_axis(&family, &betas, a0, i0, eps))
4913                    .expect("H+")
4914                    .expect("H+ some");
4915                let h_minus = family
4916                    .exact_newton_joint_hessian(&perturb_axis(&family, &betas, a0, i0, -eps))
4917                    .expect("H-")
4918                    .expect("H- some");
4919                let hand_axis = &hand[axis];
4920                for r in 0..total {
4921                    for c in 0..total {
4922                        let fd = (h_plus[[r, c]] - h_minus[[r, c]]) / (2.0 * eps);
4923                        let scale = fd.abs().max(hand_axis[[r, c]].abs()).max(1.0);
4924                        max_rel = max_rel.max((hand_axis[[r, c]] - fd).abs() / scale);
4925                    }
4926                }
4927            }
4928        }
4929        assert!(
4930            max_rel <= 1.0e-6,
4931            "softmax all-axis directional assembly drifted from the static-Hessian \
4932             finite difference by relative {max_rel:.3e}"
4933        );
4934    }
4935
4936    #[test]
4937    fn all_axis_second_directional_derivatives_match_directional_finite_difference() {
4938        let n = 10;
4939        let p = 3;
4940        let k = 4;
4941        let family = family_with_weights(
4942            n,
4943            p,
4944            k,
4945            Array1::from_shape_fn(n, |i| 0.6 + 0.3 * ((i as f64) * 0.53).cos().abs()),
4946        );
4947        let m = family.active_classes();
4948        let total = m * p;
4949        let betas = sample_betas(m, p, 0.5);
4950        let states = states_at_betas(&family, &betas);
4951        let eta = family.collect_eta_matrix(&states).expect("eta collect");
4952
4953        // Fixed first direction δ (the u-direction), a non-canonical mode so the
4954        // mechanical witness exercises the general directional jet branch.
4955        let delta = Array1::from_shape_fn(total, |idx| 0.4 * ((idx as f64 * 1.7 + 0.3).sin()));
4956
4957        let hand = family
4958            .assemble_all_axis_second_directional_derivatives(eta.view(), &delta)
4959            .expect("second-directional assembly");
4960        assert_eq!(hand.len(), total, "one second-directional matrix per axis");
4961
4962        // Mechanical witness: Hdot[δ](β) by the per-direction jet route, FD'd
4963        // along each canonical axis. Force the GENERAL-direction branch (not the
4964        // canonical-axis memo) — δ is a dense mode, so the branch is taken.
4965        let hdot_at = |st: &[ParameterBlockState]| -> Array2<f64> {
4966            family
4967                .exact_newton_joint_hessian_directional_derivative(st, &delta)
4968                .expect("Hdot")
4969                .expect("Hdot some")
4970        };
4971
4972        let eps = 1.0e-6;
4973        let mut max_rel = 0.0_f64;
4974        for a0 in 0..m {
4975            for i0 in 0..p {
4976                let axis = a0 * p + i0;
4977                let hd_plus = hdot_at(&perturb_axis(&family, &betas, a0, i0, eps));
4978                let hd_minus = hdot_at(&perturb_axis(&family, &betas, a0, i0, -eps));
4979                let hand_axis = &hand[axis];
4980                for r in 0..total {
4981                    for c in 0..total {
4982                        let fd = (hd_plus[[r, c]] - hd_minus[[r, c]]) / (2.0 * eps);
4983                        let scale = fd.abs().max(hand_axis[[r, c]].abs()).max(1.0);
4984                        max_rel = max_rel.max((hand_axis[[r, c]] - fd).abs() / scale);
4985                    }
4986                }
4987            }
4988        }
4989        assert!(
4990            max_rel <= 1.0e-5,
4991            "softmax all-axis second-directional assembly drifted from the directional \
4992             finite difference by relative {max_rel:.3e}"
4993        );
4994    }
4995
4996    /// #753 — a multinomial adapter instance can arm the universal full-span
4997    /// Jeffreys/Firth proper prior so a SEPARATING fit gets finite, bounded
4998    /// curvature instead of drifting to ±∞.
4999    ///
5000    /// `MultinomialFamily` is a `CustomFamily`, so the formula REML entry
5001    /// (`fit_penalized_multinomial_formula` → `fit_custom_family_with_rho_prior`)
5002    /// can fold the term `Φ = ½ log|Z_Jᵀ H Z_J|` into the coupled joint Newton
5003    /// solve through `build_joint_jeffreys_subspace` +
5004    /// `custom_family_joint_jeffreys_term`. Those wrappers are private to
5005    /// `custom_family.rs`, but they do exactly two things this test reproduces
5006    /// verbatim against the multinomial family's own exact joint Hessian and
5007    /// analytic directional derivative:
5008    ///   1. build the full-span basis `Z_J = I` (one identity per block,
5009    ///      stacked) via `jeffreys_subspace_from_penalty`, and
5010    ///   2. evaluate `joint_jeffreys_term(H, Z_J, ∂_β H[·])`.
5011    ///
5012    /// On a CLEANLY SEPARATED, UNPENALIZED multinomial geometry the joint
5013    /// information `H` is near-singular along the separating direction (its
5014    /// smallest eigenvalue collapses toward 0 as the iterate drifts out), the
5015    /// exact MLE-at-infinity pathology #753 is about. The assertions pin that:
5016    ///   * the conditioning gate FIRES (the term is non-trivial — `Φ`, `∇Φ`,
5017    ///     `H_Φ` are not all zero), i.e. the multinomial family is NOT silently
5018    ///     excluded from the universal robustness, and
5019    ///   * the Gauss-Newton curvature `H_Φ` is FINITE and supplies strictly
5020    ///     positive curvature on the separating direction the bare `H` does not —
5021    ///     the `O(1)`-bounding term that makes the penalized Newton iterate
5022    ///     finite (acceptance option (a)).
5023    #[test]
5024    fn separating_multinomial_arms_universal_jeffreys_firth_term() {
5025        use gam_linalg::faer_ndarray::FaerEigh;
5026        use gam_solve::estimate::reml::jeffreys_subspace::{
5027            jeffreys_subspace_from_penalty, joint_jeffreys_term,
5028        };
5029
5030        // K = 3 classes, single covariate that PERFECTLY separates the classes
5031        // by threshold, plus an intercept. Unpenalized (λ = 0, zero penalty), so
5032        // the separating slope direction has a genuine MLE at ±∞.
5033        let n = 60usize;
5034        let k = 3usize;
5035        let p = 2usize; // [intercept, x]
5036        let design = Arc::new(Array2::<f64>::from_shape_fn(
5037            (n, p),
5038            |(row, col)| match col {
5039                0 => 1.0,
5040                _ => -3.0 + 6.0 * (row as f64) / ((n - 1) as f64),
5041            },
5042        ));
5043        let mut y = Array2::<f64>::zeros((n, k));
5044        for row in 0..n {
5045            let x = design[[row, 1]];
5046            let class = if x < -1.0 {
5047                0
5048            } else if x > 1.0 {
5049                1
5050            } else {
5051                2 // reference class occupies the middle band
5052            };
5053            y[[row, class]] = 1.0;
5054        }
5055        // Unpenalized: zero penalty so NO proper wiggliness prior exists on any
5056        // direction — separation is the only thing that could bound the slope.
5057        let penalties = Arc::new(vec![crate::custom_family::PenaltyMatrix::Dense(Array2::<
5058            f64,
5059        >::zeros(
5060            (
5061            p, p,
5062        )
5063        ))]);
5064        let weights = Array1::<f64>::ones(n);
5065        let family = MultinomialFamily::new(y, weights, k, design, penalties)
5066            .expect("separated multinomial family must construct");
5067
5068        let m = family.active_classes();
5069        let total = m * p;
5070
5071        // Drive the iterate well out along the separating slope, the regime the
5072        // screening floor would otherwise leave un-bounded. Large per-class
5073        // slopes ⇒ near-saturated softmax ⇒ near-singular joint information.
5074        let betas: Vec<Array1<f64>> = (0..m)
5075            .map(|a| Array1::from_vec(vec![-300.0, 600.0 * ((a as f64) - 0.5)]))
5076            .collect();
5077        let states = states_at_betas(&family, &betas);
5078
5079        // Family's EXACT coupled joint Hessian at the separating iterate — the
5080        // same payload `custom_family_joint_jeffreys_term` pulls.
5081        let h_joint = family
5082            .exact_newton_joint_hessian(&states)
5083            .expect("joint Hessian eval")
5084            .expect("multinomial exposes an explicit joint Hessian");
5085        assert_eq!(h_joint.dim(), (total, total));
5086
5087        // Confirm the separation pathology: the joint information is genuinely
5088        // near-singular (smallest eigenvalue ≪ largest), the MLE-at-infinity
5089        // direction the Jeffreys term exists to bound.
5090        let (evals, _) = h_joint
5091            .eigh(faer::Side::Lower)
5092            .expect("information eigendecomposition");
5093        let lambda_max = evals.iter().cloned().fold(0.0_f64, f64::max);
5094        let lambda_min = evals.iter().cloned().fold(f64::INFINITY, f64::min);
5095        assert!(
5096            lambda_max > 0.0 && lambda_min / lambda_max < 1.0e-6,
5097            "fixture must be near-separating: λ_min/λ_max = {} (λ_min={lambda_min}, λ_max={lambda_max})",
5098            lambda_min / lambda_max
5099        );
5100
5101        // Full-span basis Z_J = I, block-diagonally stacked exactly as
5102        // `build_joint_jeffreys_subspace` does (each block's span is I_p).
5103        let aggregate = Array2::<f64>::zeros((p, p));
5104        let block_span = jeffreys_subspace_from_penalty(aggregate.view())
5105            .expect("block Jeffreys span")
5106            .columns;
5107        assert_eq!(block_span.dim(), (p, p));
5108        let mut z_joint = Array2::<f64>::zeros((total, total));
5109        for b in 0..m {
5110            for i in 0..p {
5111                for j in 0..p {
5112                    z_joint[[b * p + i, b * p + j]] = block_span[[i, j]];
5113                }
5114            }
5115        }
5116
5117        // Evaluate the universal Jeffreys term against the family's analytic
5118        // directional derivative — the identical closure
5119        // `custom_family_joint_jeffreys_term` constructs.
5120        let (phi, grad_phi, hphi) =
5121            joint_jeffreys_term(h_joint.view(), z_joint.view(), |direction: &Array1<f64>| {
5122                family.exact_newton_joint_hessian_directional_derivative(&states, direction)
5123            })
5124            .expect("multinomial joint Jeffreys term must evaluate");
5125
5126        // The conditioning gate must FIRE on this separating geometry: the
5127        // multinomial family is armed by the universal robustness, not excluded.
5128        let term_active =
5129            phi != 0.0 || grad_phi.iter().any(|v| *v != 0.0) || hphi.iter().any(|v| *v != 0.0);
5130        assert!(
5131            term_active,
5132            "Jeffreys/Firth term must fire on a separating multinomial fit (φ={phi})"
5133        );
5134
5135        // `H_Φ` must be finite everywhere (no inf/NaN leaking from the near-
5136        // singular information).
5137        assert!(
5138            phi.is_finite() && grad_phi.iter().all(|v| v.is_finite()),
5139            "Jeffreys φ/∇φ must be finite (φ={phi})"
5140        );
5141        for v in hphi.iter() {
5142            assert!(v.is_finite(), "H_Φ entry must be finite, got {v}");
5143        }
5144
5145        // The Gauss-Newton curvature `H_Φ` is PSD by construction; on the
5146        // separating direction (the smallest-eigenvalue eigenvector of `H`) it
5147        // must add STRICTLY POSITIVE curvature the bare information lacks — the
5148        // O(1) bound that makes `H + S_λ + H_Φ` SPD and the iterate finite.
5149        let (_, evecs) = h_joint
5150            .eigh(faer::Side::Lower)
5151            .expect("eig for separating direction");
5152        let sep_dir = evecs.column(0).to_owned(); // eigenvector of λ_min
5153        let curv_h = sep_dir.dot(&h_joint.dot(&sep_dir));
5154        let curv_hphi = sep_dir.dot(&hphi.dot(&sep_dir));
5155        assert!(
5156            curv_hphi > 0.0,
5157            "H_Φ must supply positive curvature on the separating direction (got {curv_hphi}; bare H curvature there is {curv_h})"
5158        );
5159        assert!(
5160            curv_hphi.is_finite() && curv_hphi >= curv_h,
5161            "augmented curvature {curv_hphi} must dominate the near-zero bare curvature {curv_h}"
5162        );
5163    }
5164
5165    /// A second-difference penalty on `p` coefficients: `D₂ᵀD₂` where `D₂` is the
5166    /// `(p−2)×p` second-difference operator. Rank `p−2` (nullspace = constants +
5167    /// linears), a realistic smooth-term penalty with a genuine nullspace.
5168    fn second_difference_penalty(p: usize) -> Array2<f64> {
5169        let mut s = Array2::<f64>::zeros((p, p));
5170        for r in 0..p.saturating_sub(2) {
5171            // row of D₂: [.. 1, -2, 1 ..]
5172            let d = [1.0_f64, -2.0, 1.0];
5173            for (a, &da) in d.iter().enumerate() {
5174                for (b, &db) in d.iter().enumerate() {
5175                    s[[r + a, r + b]] += da * db;
5176                }
5177            }
5178        }
5179        s
5180    }
5181
5182    /// gam#1587: the reference-symmetric centered penalty `M ⊗ S` is a symmetric
5183    /// function of all `K` classes, so its quadratic form is identical under
5184    /// every choice of reference class — while the legacy reference-anchored
5185    /// (block-diagonal `Σ_a β_aᵀ S β_a`) penalty genuinely disagrees. This is the
5186    /// pure-algebra core of the fix; the end-to-end fit invariance is verified by
5187    /// `tests/glm/families/multinomial_reference_class_invariant_1587`.
5188    #[test]
5189    fn centered_penalty_is_reference_class_invariant_1587() {
5190        let p = 5usize;
5191        let s = second_difference_penalty(p);
5192        // A fixed set of full per-class smooth coefficients γ_0,γ_1,γ_2 (K=3).
5193        // The softmax depends only on η differences, so the penalized fit must
5194        // not care which class is pinned to η ≡ 0.
5195        let gamma: [Array1<f64>; 3] = [
5196            array![0.4, -0.1, 0.7, 0.2, -0.5],
5197            array![-0.3, 0.8, 0.1, -0.6, 0.25],
5198            array![0.15, 0.05, -0.4, 0.9, -0.2],
5199        ];
5200        let k = 3usize;
5201        let m = k - 1;
5202        let metric = centered_class_metric(m, k);
5203
5204        // For reference class `r`, the active (ALR) coefficients are the two
5205        // non-reference classes' `γ_a − γ_r`. Build the stacked β^{(r)} and
5206        // evaluate both penalties.
5207        let centered_value = |r: usize| -> f64 {
5208            let actives: Vec<usize> = (0..3).filter(|&c| c != r).collect();
5209            let mut beta = Array1::<f64>::zeros(m * p);
5210            for (a, &cls) in actives.iter().enumerate() {
5211                let diff = &gamma[cls] - &gamma[r];
5212                beta.slice_mut(ndarray::s![a * p..(a + 1) * p])
5213                    .assign(&diff);
5214            }
5215            // βᵀ (M ⊗ S) β with block (a,b) = M[a,b]·S.
5216            let mut acc = 0.0;
5217            for a in 0..m {
5218                for b in 0..m {
5219                    let ba = beta.slice(ndarray::s![a * p..(a + 1) * p]);
5220                    let bb = beta.slice(ndarray::s![b * p..(b + 1) * p]);
5221                    acc += metric[[a, b]] * ba.dot(&s.dot(&bb));
5222                }
5223            }
5224            acc
5225        };
5226        let diagonal_value = |r: usize| -> f64 {
5227            let actives: Vec<usize> = (0..3).filter(|&c| c != r).collect();
5228            actives
5229                .iter()
5230                .map(|&cls| {
5231                    let diff = &gamma[cls] - &gamma[r];
5232                    diff.dot(&s.dot(&diff))
5233                })
5234                .sum()
5235        };
5236
5237        let c0 = centered_value(0);
5238        let c1 = centered_value(1);
5239        let c2 = centered_value(2);
5240        assert!(
5241            (c0 - c1).abs() < 1e-12 && (c0 - c2).abs() < 1e-12,
5242            "centered penalty must be reference-invariant: {c0} {c1} {c2}"
5243        );
5244        // And it equals the symmetric CLR form Σ_k (γ_k − γ̄)ᵀ S (γ_k − γ̄).
5245        let mean: Array1<f64> = (&gamma[0] + &gamma[1] + &gamma[2]) / 3.0;
5246        let clr: f64 = gamma
5247            .iter()
5248            .map(|g| {
5249                let c = g - &mean;
5250                c.dot(&s.dot(&c))
5251            })
5252            .sum();
5253        assert!(
5254            (c0 - clr).abs() < 1e-10,
5255            "centered penalty {c0} must equal the CLR form {clr}"
5256        );
5257
5258        // The legacy reference-anchored penalty genuinely DEPENDS on r (the bug).
5259        let d0 = diagonal_value(0);
5260        let d1 = diagonal_value(1);
5261        let d2 = diagonal_value(2);
5262        let diag_spread = (d0 - d1).abs().max((d0 - d2).abs()).max((d1 - d2).abs());
5263        assert!(
5264            diag_spread > 1e-6,
5265            "reference-anchored penalty should differ across references (reproducing the bug); spread {diag_spread}"
5266        );
5267    }
5268
5269    /// `M ⊗ S` is symmetric PSD with the declared nullspace `(K−1)·ns(S)`, the
5270    /// contract `JointPenaltySpec::validate` and the outer pseudo-logdet rely on.
5271    #[test]
5272    fn centered_joint_penalty_spec_is_psd_with_declared_nullspace_1587() {
5273        use gam_linalg::faer_ndarray::FaerEigh;
5274        let p = 5usize;
5275        let s = second_difference_penalty(p); // rank p-2 ⇒ ns(S) = 2
5276        let k = 4usize; // K=4 ⇒ m=3
5277        let m = k - 1;
5278        let metric = centered_class_metric(m, k);
5279        let raw_total = m * p;
5280        let mut matrix = Array2::<f64>::zeros((raw_total, raw_total));
5281        for a in 0..m {
5282            for b in 0..m {
5283                for i in 0..p {
5284                    for j in 0..p {
5285                        matrix[[a * p + i, b * p + j]] = metric[[a, b]] * s[[i, j]];
5286                    }
5287                }
5288            }
5289        }
5290        // Symmetric.
5291        for i in 0..raw_total {
5292            for j in 0..raw_total {
5293                assert!((matrix[[i, j]] - matrix[[j, i]]).abs() < 1e-14);
5294            }
5295        }
5296        let (evals, _) = FaerEigh::eigh(&matrix, faer::Side::Lower).expect("eigh");
5297        let mut sorted: Vec<f64> = evals.iter().copied().collect();
5298        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
5299        // PSD: no meaningfully negative eigenvalue.
5300        assert!(sorted[0] > -1e-10, "M⊗S must be PSD; min eig {}", sorted[0]);
5301        // Nullspace dim = (K-1)·ns(S) = 3·2 = 6.
5302        let zeros = sorted.iter().take_while(|&&v| v.abs() < 1e-9).count();
5303        assert_eq!(
5304            zeros,
5305            m * 2,
5306            "nullspace dim must be (K-1)·ns(S); spectrum {sorted:?}"
5307        );
5308    }
5309}