Skip to main content

gam_models/
multinomial.rs

1//! Penalized multinomial-logit (softmax) GLM driver — fixed-λ inner solve.
2//!
3//! This is the principled vector-response companion to the scalar PIRLS path:
4//! the inner-loop Newton solver for a multi-class GAM at fixed smoothing
5//! parameters λ, using the canonical multinomial-logit likelihood
6//! ([`MultinomialLogitLikelihood`]) and the existing dense block-Fisher
7//! assembly in [`gam_solve::pirls::dense_block_xtwx`] /
8//! [`gam_solve::pirls::dense_block_xtwy`].
9//!
10//! # What this module does
11//!
12//! Solve, for the reference-coded multinomial-logit GAM with `K` classes and
13//! design matrix `X ∈ ℝ^{N×P}`,
14//!
15//! ```text
16//!     β̂ = argmin_β { − log L(β) + ½ Σ_{a=0}^{K-2} λ_a · β_a^T S β_a }
17//! ```
18//!
19//! where `β = [β_0; β_1; …; β_{K-2}]` is the stacked coefficient vector in
20//! output-major order (`β_a ∈ ℝ^P` is the coefficient block for class `a`),
21//! `S ∈ ℝ^{P×P}` is the smoothing penalty matrix (shared across classes,
22//! replicated as `I_{K-1} ⊗ S` over the full parameter space), and `λ_a` is
23//! a per-class smoothing parameter.
24//!
25//! The likelihood uses class `K - 1` as the reference (`η_{K-1} ≡ 0`), so the
26//! softmax gauge is fixed at the η level and no additional sum-to-zero
27//! projection is required.
28//!
29//! # Layering
30//!
31//! * **Fixed-λ inner solve** — [`fit_penalized_multinomial`] is the canonical
32//!   coefficient-space Newton solver at *given* smoothing parameters `λ`,
33//!   built on the shared [`crate::penalized_vector_glm`] engine.
34//!
35//! * **REML / LAML smoothing-parameter selection** — [`fit_penalized_multinomial_formula`]
36//!   routes through [`crate::custom_family::fit_custom_family_with_rho_prior`]
37//!   so the per-active-class `λ_a` are selected by the outer REML/LAML loop;
38//!   the caller's `init_lambda` is only a warm-start seed. The multinomial
39//!   [`crate::multinomial_reml::MultinomialFamily`] `CustomFamily`
40//!   impl calls the fixed-λ math above as its inner solve at each ρ trial and
41//!   supplies the dense per-row Hessian block for the outer trace terms.
42//!
43//! * **Formula → design integration** — `build_formula_design_for_multinomial`
44//!   parses the Wilkinson formula and assembles `X` and the per-term `S`
45//!   blocks; the `fit_multinomial_formula_pyfunc` FFI shim wires the Python
46//!   `gamfit.fit(..., family='multinomial')` entry straight to this path.
47//!
48//! # Convergence
49//!
50//! The damped-Newton-with-backtracking scaffold lives once in the shared
51//! [`crate::penalized_vector_glm`] engine: at each iteration the
52//! assembled penalized Hessian `H + I_{K-1} ⊗ (λ_a S)` is factored via faer's
53//! symmetric-PD-with-fallback path, the full Newton step `δ = −H^{-1} ∇F` is
54//! computed, and accepted with step halving if the objective fails to decrease
55//! (up to a small backtracking budget). Convergence requires both a relative
56//! coefficient step `‖δ‖ / (1 + ‖β‖) ≤ tol` and a fresh first-order score
57//! certificate at the accepted final iterate; failure produces checkpoint
58//! evidence, never coefficients/covariance behind a false flag. This module is
59//! the softmax adapter over that engine: it
60//! supplies the dense `(K-1)×(K-1)` Fisher block, the residual, and the
61//! log-likelihood through [`MultinomialLogitLikelihood`], and owns the
62//! class-count / simplex preconditions. The independent-binomial sibling
63//! [`crate::binomial_multi`] is the same engine with a row-diagonal
64//! Fisher block instead.
65
66use crate::custom_family::{
67    BlockwiseFitOptions, ParameterBlockState, PenaltyMatrix, fit_custom_family_with_rho_prior,
68};
69use crate::fit_orchestration::drivers::freeze_term_collection_from_design;
70use crate::fit_orchestration::{
71    FitConfig, build_termspec_with_geometry_and_overrides, resolved_resource_policy,
72};
73use crate::model_types::EstimationError;
74use crate::multinomial_reml::MultinomialFamily;
75use crate::multinomial_posterior::{
76    MultinomialPosteriorIntegrationControl, integrate_multinomial_design_moments,
77};
78use crate::penalized_vector_glm::{
79    PenalizedVectorGlmInputs, VectorGlmResume, VectorGlmSolve, fit_penalized_vector_glm,
80};
81use crate::vector_response::{MultinomialLogitLikelihood, validate_multinomial_simplex};
82use gam_data::ColumnKindTag;
83use gam_data::EncodedDataset;
84use gam_problem::{
85    FixedLambdaCheckpoint, FixedLambdaResidualKind, FixedLambdaSolverStage, FixedLambdaStallReason,
86    FixedLambdaStationarityEvidence, ResponseColumnKind,
87};
88use gam_runtime::resource::ProblemHints;
89use gam_terms::inference::formula_dsl::parse_formula;
90use gam_terms::smooth::{
91    PenaltyBlockInfo, TermCollectionDesign, TermCollectionSpec, build_term_collection_design,
92};
93use gam_terms::term_builder::resolve_role_col;
94use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayView3};
95use opt::{BacktrackConfig, backtracking_line_search};
96use serde::{Deserialize, Serialize};
97use std::convert::Infallible;
98use std::sync::Arc;
99
100/// Solver-only numerical stabilization floor for the formula-driven
101/// multinomial REML inner solve (gam#747).
102///
103/// Installed with [`RidgePolicy::solver_only`](gam_problem::RidgePolicy::solver_only)
104/// so it stabilizes the inner joint-Newton **linear solve** but never enters
105/// the REML objective, the penalty log-determinant, or the Laplace Hessian.
106///
107/// What it does: the multinomial smoothing penalties are rank-deficient by
108/// design (each smooth carries an unpenalized polynomial null space) and the
109/// formula may add a fully unpenalized parametric term (`x3` / `body_mass`). On
110/// near-separable hard labels the softmax curvature is ill-conditioned along
111/// those directions, so the bare Newton step `H⁻¹∇` is huge. Lifting the
112/// smallest Hessian eigenvalue to `δ` bounds the step (`‖(H+δI)⁻¹∇‖ ≤ ‖∇‖/δ`),
113/// keeping the screening iterates finite without poisoning the softmax with
114/// `inf − inf = NaN`.
115///
116/// What it deliberately does NOT do: it adds no `½·δ·‖β‖²` term to the
117/// objective and no `δ`-shift to the REML log-determinant. The earlier
118/// `explicit_stabilization_pospart` policy folded both into the criterion,
119/// which made `1e-4` a fixed-λ Gaussian prior that shrank every identified
120/// coefficient off the MLE and biased smoothing-parameter selection — a value
121/// that had to be tuned *between* under-stabilization (NaN seeds) and
122/// over-shrinkage (lost VGAM match). As a solver-only floor that tradeoff is
123/// gone: the over-shrinkage failure mode cannot occur (nothing is shrunk), the
124/// optimized objective is the true penalized REML criterion, and the floor
125/// only has to be large enough to keep the linear algebra finite.
126///
127/// The separation defect (#753) is no longer this floor's job. If the
128/// multinomial MLE is genuinely at infinity for an unpenalized/null-space
129/// direction (complete/quasi-complete separation), no solver floor makes that
130/// direction's estimate finite. The formula REML path arms the full-span
131/// Jeffreys/Firth correction CONDITIONALLY — only on separation evidence (see
132/// [`multinomial_formula_separation_evidence`] and the two-attempt logic in
133/// [`fit_penalized_multinomial_formula`]) — so an interior, well-identified fit
134/// optimizes the unbiased penalized-REML criterion with no Firth shrinkage
135/// toward the uniform simplex, while a (quasi-)separated geometry gets the
136/// proper prior that is the only thing able to bound its penalty-null
137/// directions (#715 real-data arm). The bare fixed-λ inner driver
138/// [`fit_penalized_multinomial`] (no outer REML, no Jeffreys term) surfaces the
139/// explicit `MultinomialSeparationDetected` diagnostic for the path that has no
140/// proper prior to lean on.
141const MULTINOMIAL_FORMULA_RIDGE_FLOOR: f64 = 1.0e-4;
142
143/// Inner joint-Newton KKT tolerance for the multinomial formula path.
144///
145/// The softmax Fisher weight `W = diag(p) − ppᵀ` collapses on saturated rows,
146/// so near-separable fits (penguins, #715) reach the OBJECTIVE's f64 noise
147/// floor before the default `inner_tol = 1e-6` KKT target: measured on the
148/// penguins arm (standardized columns), the trust region collapses to 1e-12
149/// with per-attempt objective changes of ~+2e-9 on |obj| ≈ 1e2 (≈ 1e-11
150/// relative — pure rounding) while the KKT residual plateaus at 2.8e-5–9.4e-5
151/// against a scaled tolerance of ~1.9e-5. Demanding a residual below the
152/// floating-point noise floor is certifiable-never: every eval is rejected by
153/// the stall guard and the whole fit fails. `1e-5` certifies the measured
154/// plateaus while still resolving β to ~1e-6 in the relevant metric — the
155/// LAML criterion consumes β̂ with error O(residual²/curvature), far below
156/// any quantity the outer ρ-search can read.
157const MULTINOMIAL_FORMULA_INNER_TOL: f64 = 1.0e-5;
158
159/// Formula-adapter penalty calibration for multinomial softmax REML.
160///
161/// The term builder's normalized penalties are calibrated on single-response
162/// Gaussian-style score curvature. A reference-coded softmax class block sees
163/// per-row active-class Fisher diagonal `p_a(1-p_a)` plus negative cross-class
164/// coupling. At the neutral simplex (`p_k = 1/K`) the active diagonal is
165/// `(K-1)/K²`, so the binary-logit calibration is `2·(K-1)/K² = 1/2` and the
166/// three-class calibration is `4/9` rather than the historical hard-coded
167/// `1/2`. Making the scale a function of `K` keeps the physical smoothness
168/// prior tied to the likelihood curvature instead of over-penalizing every
169/// class as the simplex gains categories.
170fn multinomial_formula_penalty_scale(n_classes: usize) -> f64 {
171    let k = n_classes.max(2) as f64;
172    2.0 * (k - 1.0) / (k * k)
173}
174
175/// Largest smoothing-parameter dimension where exact dense outer curvature is
176/// still worth paying for multinomial formula fits.
177///
178/// `D = (K - 1) * n_penalties`. Medium-size loaded models use exact curvature
179/// so the optimizer does not wander into over-smoothed lambda caps on
180/// near-boundary softmax surfaces. The threshold was originally calibrated at
181/// `D <= 6` when each `s()` term carried ONE penalty; the double-penalty
182/// migration (wiggliness + null-space shrinkage per term, mgcv `select=TRUE`
183/// semantics) doubled `D` for the SAME models, silently flipping the
184/// reference formula fits (2 smooths, K = 3: old `D = 4`, now `D = 8`) onto
185/// the gradient-only route — where the #715 quality arm showed every
186/// wiggliness ρ driven onto the ±10 box bound (smooths collapsed toward their
187/// polynomial null space, truth-RMSE behind VGAM). `12 = 2 × 6` preserves the
188/// original classification boundary under the doubled penalty count while
189/// keeping the four-smooth penguin species quality fixture on the exact ARC
190/// path: that model is `D = 16`, and first-order BFGS can cycle along the
191/// near-separable lambda-to-zero ridge until the wall-clock budget expires
192/// (#1082). ARC observes the same exact curvature and can halt through the
193/// bound-aware cost-stall guard once the REML surface stops making useful
194/// progress.
195const MULTINOMIAL_EXACT_OUTER_HESSIAN_MAX_DIM: usize = 16;
196
197fn multinomial_formula_use_outer_hessian(total_rho_dim: usize) -> bool {
198    total_rho_dim <= MULTINOMIAL_EXACT_OUTER_HESSIAN_MAX_DIM
199}
200
201/// Logit magnitude beyond which fitted probabilities are saturated at ordinary
202/// double precision diagnostic scale. The bare fixed-λ driver has no outer REML
203/// state and still uses this threshold to reject a non-converged saturated
204/// iterate as a separation artifact. The formula REML path does not use this as
205/// a Firth trigger: with smoothing parameters selected, a finite saturated
206/// surface can be the valid near-separated optimum that should be scored
207/// directly.
208const MULTINOMIAL_SEPARATION_ETA_THRESHOLD: f64 = 25.0;
209
210/// Calibrated convergence tolerance for the OUTER REML/LAML smoothing-parameter
211/// search on the formula multinomial path. Matches the primary GLM REML outer
212/// (`solver::fit_orchestration::materialize` uses `tol = 1e-7`, mirrored by the
213/// `LOG_LAMBDA_TOL` / `KKT_TOL_*` constants across the REML stack): tight enough
214/// that the selected λ reaches the genuine REML optimum (the recovered
215/// probability surface matches the mature reference), loose enough that the
216/// optimizer does not grind surface-irrelevant ρ digits down to the inner KKT
217/// scale (the #1082 wall-clock overrun). The caller's `tol` is floored at this
218/// value for the OUTER loop, while it continues to drive the INNER joint-Newton
219/// KKT target unchanged.
220const MULTINOMIAL_OUTER_REML_TOL: f64 = 1e-7;
221
222/// The first multinomial formula solve is a separation probe: it is accepted
223/// when the unbiased REML criterion converges to a finite interior iterate.
224/// Near-separable data such as the penguin fixture otherwise spend the caller's
225/// full outer budget on an iterate that is discarded before the Firth/Jeffreys
226/// refit. Keep enough iterations for ordinary interior fits to certify quickly,
227/// but hand slow/non-interior probes to the proper-prior refit promptly.
228const MULTINOMIAL_UNBIASED_PROBE_OUTER_MAX_ITER: usize = 20;
229
230/// Per-observation softmax Fisher-information scale for the λ-floor units.
231///
232/// The penalty enters the criterion as `½ λ βᵀ S β` with a Frobenius-normalized
233/// `S` (`‖S‖_F = 1`, see the term-builder calibration referenced by
234/// [`multinomial_formula_penalty_scale`]), so the ridge `λ S` is directly
235/// comparable to data Fisher information. One observation contributes softmax
236/// information `p(1−p)` in a class's logit direction, which is bounded by the
237/// logistic peak `p(1−p) ≤ ¼` at `p = ½`. Using this maximal per-observation
238/// information as the unit makes the floor's strength interpretable as a count
239/// of equivalent **pseudo-observations** of prior: a ridge that equals
240/// `τ · ¼ · ‖S‖_F` carries the same logit-direction curvature as `τ` real rows
241/// sitting at the most-informative point of the likelihood. This scale is
242/// `K`-independent on purpose — the `K`-dependence of the softmax block
243/// curvature already lives in the penalty matrix via
244/// [`multinomial_formula_penalty_scale`], so the floor (a bound on the
245/// multiplier of that already-scaled penalty) must not double-count it.
246const MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS: f64 = 0.25;
247
248/// Target prior strength of the λ-floor, in pseudo-observations, for a
249/// WELL-SUPPORTED class. The floor holds the unbiased REML optimizer off the
250/// zero-penalty boundary (where a boundary-overfit smooth or a Firth switch on
251/// finite data would otherwise be accepted) with a prior worth a fixed small
252/// fraction of one observation. `8e-4` pseudo-observations reproduces the
253/// previously fixture-calibrated large-support floor `τ · ¼ = 2e-4` exactly at
254/// the calibration point, now expressed as an effective-prior-strength rather
255/// than a tuned λ value.
256const MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS: f64 = 8.0e-4;
257
258/// Reference class support `n_ref`: the effective sample size per class at which
259/// the data Fisher information `n_c · I₁` is large enough that the floor sits at
260/// its well-supported value. Below `n_ref` the per-class data information shrinks
261/// like `n_c`, so to keep the floor's prior from vanishing *relative to* that
262/// shrinking data the effective pseudo-observation count is scaled up by
263/// `n_ref / n_c` (the prior is held to a fixed fraction of the data information,
264/// not a fixed absolute λ). At `n_c = n_ref` the scale is exactly 1.
265const MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT: f64 = 50.0;
266
267/// Cap on the floor's prior strength in the very-sparse limit, in
268/// pseudo-observations. As `n_c → 0` the `n_ref / n_c` scaling diverges; the cap
269/// holds the prior at `4e-3` pseudo-observations (`τ_max · ¼ = 1e-3` at the
270/// calibration point, the previously-tuned strong-floor value) so the floor
271/// stays a proper prior rather than a hard constraint that would dominate the
272/// likelihood for a handful-of-rows class.
273const MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX: f64 = 4.0e-3;
274
275/// Continuous, Fisher-information-scaled lower λ floor for the formula path,
276/// derived from the minority class's effective sample size `n_c`.
277///
278/// # Derivation (effective-prior-strength / Fisher geometry)
279///
280/// The penalty `½ λ βᵀ S β` with `‖S‖_F = 1` adds curvature `λ` to the class
281/// logit direction; one observation adds at most `I₁ = ¼` there. So a floor that
282/// sets `λ_floor = τ_eff · I₁` gives the smooth a prior worth `τ_eff`
283/// pseudo-observations. We want a fixed *absolute* prior `τ` for a well-supported
284/// class, but for a minority class with only `n_c` effective observations the
285/// data information in its block is `n_c · I₁`; holding the prior to a fixed
286/// *fraction* of that shrinking data information requires
287///
288/// ```text
289///     τ_eff(n_c) = τ · max(1, n_ref / n_c),   clamped to [τ, τ_max]
290///     λ_floor(n_c) = τ_eff(n_c) · I₁
291/// ```
292///
293/// This is the *same* `base · max(1, c0/c)` envelope as before — but `base`,
294/// `sparse`, and `c0` are no longer fixture-tuned magic numbers: `base = τ·I₁`,
295/// `sparse = τ_max·I₁`, and `c0 = n_ref` are an effective-prior-strength of
296/// `τ`/`τ_max` pseudo-observations against the maximal per-observation softmax
297/// information `I₁ = ¼`. Properties preserved by construction:
298///   * reduces EXACTLY to `τ·I₁` for well-supported classes (`n_c ≥ n_ref`);
299///   * reduces EXACTLY to `τ_max·I₁` for very sparse classes
300///     (`n_c ≤ n_ref·τ/τ_max`, here `n_c ≤ 10`);
301///   * interpolates monotonically and continuously between them in the middle —
302///     no cliff at `n_c = n_ref`.
303/// At the calibration point the endpoints equal the previous `2e-4` / `1e-3`, so
304/// fixtures whose smallest class has `n_c ≥ 50` (penguins, the vgam softmax
305/// arms) are unaffected — they sit at `τ·I₁ = 2e-4` exactly as before.
306fn multinomial_formula_min_lambda(y_one_hot: ArrayView2<'_, f64>) -> f64 {
307    let base = MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
308    let sparse =
309        MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
310    let min_class_count = (0..y_one_hot.ncols())
311        .map(|class| y_one_hot.column(class).sum())
312        .fold(f64::INFINITY, f64::min);
313    if !min_class_count.is_finite() || min_class_count <= 0.0 {
314        return base;
315    }
316    // Effective pseudo-observation prior strength: held to a fixed fraction of
317    // the shrinking per-class data information once n_c falls below n_ref.
318    let pseudo_obs_scale =
319        (MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT / min_class_count).max(1.0);
320    (base * pseudo_obs_scale).clamp(base, sparse)
321}
322
323fn max_abs_eta_location(eta: ArrayView2<'_, f64>) -> (f64, usize, usize) {
324    let mut best = (0.0_f64, 0usize, 0usize);
325    for ((row, active_class), &value) in eta.indexed_iter() {
326        let abs = value.abs();
327        if abs > best.0 {
328            best = (abs, row, active_class);
329        }
330    }
331    best
332}
333
334/// Separation gate for the REML/LAML **formula** path.
335///
336/// Unlike the bare fixed-λ driver [`fit_penalized_multinomial`] (which has no
337/// outer REML state and so must reject a saturated, non-converged iterate as a
338/// separation artifact at the [`MULTINOMIAL_SEPARATION_ETA_THRESHOLD`] logit
339/// magnitude), the formula path can return a finite saturated mode after the
340/// coupled outer optimizer has selected smoothing parameters. A `|η| >= 25`
341/// gate is therefore wrong here: the penguins arm can legitimately have large
342/// fitted logits while still producing finite probabilities and a usable REML
343/// mode.
344///
345/// Only a genuinely NON-FINITE `η` (a NaN/Inf blow-up in the inner linear
346/// algebra) is a real formula-path failure. A finite, even saturated, `η` is
347/// accepted so the truth-recovery / match-or-beat bars are evaluated against the
348/// actual fitted surface instead of an adapter diagnostic.
349fn multinomial_formula_separation_diagnostic(
350    inner_cycles: usize,
351    outer_iterations: usize,
352    block_states: &[ParameterBlockState],
353) -> Option<EstimationError> {
354    let mut nonfinite: Option<(f64, usize, usize)> = None;
355    for (active_class, state) in block_states.iter().enumerate() {
356        for (row, &value) in state.eta.iter().enumerate() {
357            if !value.is_finite() {
358                nonfinite = Some((value, row, active_class));
359                break;
360            }
361        }
362        if nonfinite.is_some() {
363            break;
364        }
365    }
366    nonfinite.map(|(value, row_index, active_class_index)| {
367        EstimationError::MultinomialSeparationDetected {
368            iteration: inner_cycles.max(outer_iterations),
369            max_abs_eta: value.abs(),
370            active_class_index,
371            row_index,
372        }
373    })
374}
375
376/// Separation EVIDENCE gate for the conditional Firth/Jeffreys engagement on
377/// the formula REML path (#715 / #753).
378///
379/// The structural mathematics (#715 issue thread): for any coefficient
380/// direction `v` with `S v = 0` (a penalty-null direction — intercept, a
381/// smooth's polynomial null component, an unpenalized parametric term), the
382/// penalized joint Hessian satisfies `(H + S_λ) v = H v` for EVERY smoothing
383/// parameter ρ. When the data (quasi-)separate, the softmax Fisher weight
384/// `W = diag(p) − p pᵀ → 0` on the saturated rows, so `H v = JᵀWJ v → 0` along
385/// the penalty-null directions those rows support: `(H + S_λ) v ≈ 0` for every
386/// ρ — NO λ can repair it, the inner Newton can never certify a KKT point
387/// there, and every outer REML startup seed is rejected (the penguins
388/// real-data arm). The only principled cure is a PROPER prior on that
389/// quotient-null subspace — the Jeffreys/Firth term `Φ = ½ log|ZᵀHZ|`, whose
390/// Gauss–Newton curvature supplies the missing `O(1)` bound.
391///
392/// But the Firth prior is not free on interior data: unconditionally armed, it
393/// shrinks fitted class probabilities toward the uniform simplex `1/K`
394/// (an `O(1/n)` pull that the synthetic match-or-beat arm of #715 measured as
395/// a real truth-RMSE loss vs the unbiased criterion). So the formula path
396/// engages it ONLY on separation evidence, mirroring the #753 "diagnose, then
397/// arm" split:
398///
399/// * a NON-FINITE logit — the inner linear algebra blew up along an unbounded
400///   direction.
401///
402/// Returns `Some(description)` naming the witnessing logit when evidence is
403/// found, `None` for a finite fit (which is then accepted as-is, with zero
404/// Firth bias). A FAILED unbiased solve (`Err` from the rho-prior driver, e.g.
405/// "no startup seed passed") is the second evidence form and is handled
406/// directly at the call site in [`fit_penalized_multinomial_formula`].
407fn multinomial_formula_separation_evidence(block_states: &[ParameterBlockState]) -> Option<String> {
408    for (active_class, state) in block_states.iter().enumerate() {
409        for (row, &value) in state.eta.iter().enumerate() {
410            if !value.is_finite() {
411                return Some(format!(
412                    "non-finite logit eta[row {row}, active class {active_class}] = {value}"
413                ));
414            }
415        }
416    }
417    None
418}
419
420/// Extra evidence used only for a NON-CONVERGED capped unbiased probe.
421///
422/// A converged finite saturated formula fit is still a valid optimum and must be
423/// scored without Firth bias. A capped probe that failed to converge while it
424/// already carries separation-scale logits is different: spending the full
425/// unbiased outer budget on the same lambda-to-zero surface is the #1082
426/// Inputs to [`fit_penalized_multinomial`].
427///
428/// The penalty matrix `S` is shared across classes; per-class smoothing
429/// parameters `lambdas` (length `K - 1`) scale `S` independently for each
430/// active class. The full block-replicated penalty is `diag_a(λ_a) ⊗ S`,
431/// which is exactly what [`gam_solve::arrow_schur::KroneckerPenaltyOp`]
432/// expresses in matrix-free form when this driver is later lifted into the
433/// arrow-Schur loop.
434#[derive(Debug, Clone)]
435pub struct MultinomialFitInputs<'a> {
436    /// Design matrix `X ∈ ℝ^{N×P}` (one row per observation).
437    pub design: ArrayView2<'a, f64>,
438    /// Categorical response `Y ∈ ℝ^{N×K}`. Each row must be a point on the
439    /// probability simplex (`y_c ≥ 0`, `Σ_c y_c = 1`): a one-hot indicator for
440    /// hard classification, or a label-smoothed probability vector. Rows whose
441    /// mass departs from 1 are rejected — the softmax residual gradient and
442    /// Fisher block are the derivatives of `Σ_c y_c log p_c` only under the
443    /// simplex constraint (see `validate_multinomial_simplex`).
444    pub y_one_hot: ArrayView2<'a, f64>,
445    /// Shared smoothing penalty `S ∈ ℝ^{P×P}` (symmetric, PSD).
446    pub penalty: ArrayView2<'a, f64>,
447    /// Per-active-class smoothing parameter `λ_a` (length `K - 1`).
448    pub lambdas: ArrayView1<'a, f64>,
449    /// Optional per-row weights (length `N`); `None` ⇒ uniform 1.0.
450    pub row_weights: Option<ArrayView1<'a, f64>>,
451    /// Optional per-row Fisher-block override, shape `(N, K-1, K-1)` in the
452    /// active-class gauge (the reference class `K-1` is dropped). When `Some`,
453    /// each Newton step uses this block as the curvature `W` in place of the
454    /// analytic softmax Fisher `w_n (δ_ab p_a − p_a p_b)`; the gradient/residual
455    /// path stays analytic, so this is a curvature-only override (the
456    /// research escape-hatch for latent multinomial fits, issue #349). Each
457    /// per-row block must be symmetric, PSD, and finite — preconditions the
458    /// FFI boundary discharges before constructing this view.
459    pub fisher_w_override: Option<ArrayView3<'a, f64>>,
460    /// Maximum Newton iterations; recommend 50.
461    pub max_iter: usize,
462    /// Relative-step convergence tolerance; recommend 1e-7.
463    pub tol: f64,
464    /// Optional checkpoint emitted by a prior fixed-λ multinomial stall on
465    /// the same design, response, weights, offsets, penalty, and lambdas. A
466    /// `MultinomialNewton` checkpoint resumes the ordinary softmax objective;
467    /// a `MultinomialFirth` checkpoint resumes the Jeffreys/Firth separation
468    /// objective directly. Any other stage or coefficient shape is rejected.
469    pub resume_from: Option<&'a FixedLambdaCheckpoint>,
470}
471
472/// Outputs of [`fit_penalized_multinomial`].
473#[derive(Debug, Clone)]
474pub struct MultinomialFitOutputs {
475    /// Active-class coefficient block, shape `(P, K-1)` (column `a` is `β_a`).
476    /// The reference class `K - 1` has `β_{K-1} ≡ 0` by construction and is
477    /// not stored.
478    pub coefficients_active: Array2<f64>,
479    /// Fitted probabilities, shape `(N, K)`.
480    pub fitted_probabilities: Array2<f64>,
481    /// Number of Newton iterations executed (including the final step that
482    /// satisfied the tolerance). Non-convergence (outside the separation lane,
483    /// which escalates to the Firth refit) is surfaced as the typed
484    /// [`EstimationError::FixedLambdaNewtonDidNotConverge`] rather than an `Ok`
485    /// with a flag, so every constructed value of this struct is a certified
486    /// converged fit (SPEC: a fit only ever comes from a converged
487    /// optimization).
488    pub iterations: usize,
489    /// Penalized negative log-likelihood at the returned `β̂`:
490    /// `−log L(β̂) + ½ Σ_a λ_a · β̂_a^T S β̂_a`.
491    pub penalized_neg_log_likelihood: f64,
492    /// Unpenalized deviance `−2 log L(β̂)` for diagnostic reporting.
493    pub deviance: f64,
494    /// Joint Laplace posterior coefficient covariance `H⁻¹` at the converged
495    /// `β̂`, shape `(P·(K−1))×(P·(K−1))` (#1101). Block-ordered to match the
496    /// stacked active-class coefficient vector `β = [β_0; …; β_{K-2}]`: active
497    /// class `a`'s `P` coefficients occupy rows/cols `a·P .. (a+1)·P`, indexed
498    /// `θ[a·P + i] = β̂[i, a]`. This is the Laplace covariance from the factored
499    /// penalized Hessian `XᵀWX + diag_a(λ_a)⊗S`; it drives the delta-method
500    /// per-class probability standard errors ([`Self::predict_probabilities_with_se`])
501    /// on the fixed-λ inner-solve path.
502    pub coefficient_covariance: Array2<f64>,
503}
504
505impl MultinomialFitOutputs {
506    /// Number of active classes `M = K − 1` (columns of
507    /// [`Self::coefficients_active`]).
508    pub fn n_active_classes(&self) -> usize {
509        self.coefficients_active.ncols()
510    }
511
512    /// Per-class coefficient dimension `P` (rows of
513    /// [`Self::coefficients_active`]).
514    pub fn p_per_class(&self) -> usize {
515        self.coefficients_active.nrows()
516    }
517
518    /// Integrate the logistic-normal coefficient posterior at fresh design rows.
519    /// Returns posterior-mean class probabilities and their exact-under-the-
520    /// quadrature marginal standard deviations. The full joint coefficient
521    /// covariance, including cross-class blocks, is contracted into each row's
522    /// active-logit covariance before deterministic adaptive integration.
523    pub fn predict_probabilities_with_se(
524        &self,
525        x_new: ArrayView2<'_, f64>,
526    ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
527        self.predict_probabilities_with_se_and_control(
528            x_new,
529            &MultinomialPosteriorIntegrationControl::default(),
530        )
531    }
532
533    pub fn predict_probabilities_with_se_and_control(
534        &self,
535        x_new: ArrayView2<'_, f64>,
536        control: &MultinomialPosteriorIntegrationControl,
537    ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
538        let moments = integrate_multinomial_design_moments(
539            self.coefficients_active.view(),
540            self.coefficient_covariance.view(),
541            x_new,
542            control,
543        )?;
544        Ok((moments.class_mean, moments.class_standard_deviation))
545    }
546}
547
548#[derive(Clone, Copy)]
549struct FirthResume<'a> {
550    coefficients: ArrayView2<'a, f64>,
551    completed_iterations: usize,
552}
553
554fn fixed_lambda_checkpoint_coefficients(
555    checkpoint: &FixedLambdaCheckpoint,
556    expected_stage: FixedLambdaSolverStage,
557    p: usize,
558    m: usize,
559) -> Result<Array2<f64>, EstimationError> {
560    checkpoint.validate().map_err(|reason| {
561        EstimationError::InvalidInput(format!(
562            "multinomial fixed-λ resume checkpoint is invalid: {reason}"
563        ))
564    })?;
565    if checkpoint.stage() != expected_stage {
566        crate::bail_invalid_estim!(
567            "multinomial fixed-λ resume checkpoint stage is {}, expected {}",
568            checkpoint.stage(),
569            expected_stage,
570        );
571    }
572    if checkpoint.rows() != p || checkpoint.cols() != m {
573        crate::bail_invalid_estim!(
574            "multinomial fixed-λ resume checkpoint shape {}x{} does not match P x (K-1) = {p}x{m}",
575            checkpoint.rows(),
576            checkpoint.cols(),
577        );
578    }
579    Array2::from_shape_vec((p, m), checkpoint.values().to_vec()).map_err(|error| {
580        EstimationError::InvalidInput(format!(
581            "multinomial fixed-λ resume checkpoint could not be reshaped: {error}"
582        ))
583    })
584}
585
586/// Fit a penalized multinomial-logit GAM at fixed `λ`.
587///
588/// See the module docs for the optimization problem and conventions. This
589/// function is the canonical inner solve: the outer REML/LAML loop, when
590/// added, calls this at each `ρ = log λ` trial.
591pub fn fit_penalized_multinomial(
592    inputs: MultinomialFitInputs<'_>,
593) -> Result<MultinomialFitOutputs, EstimationError> {
594    let MultinomialFitInputs {
595        design,
596        y_one_hot,
597        penalty,
598        lambdas,
599        row_weights,
600        fisher_w_override,
601        max_iter,
602        tol,
603        resume_from,
604    } = inputs;
605
606    // ──────────────────────── family-specific validation ───────────────────
607    // The shared engine re-validates the geometry common to every vector-GLM
608    // (nonempty design, penalty shape, λ finiteness/non-negativity, override
609    // `(N, M, M)` shape, finite design). The multinomial family owns the
610    // class-count contract (`K ≥ 2`, λ length `K − 1`), the per-row simplex
611    // precondition under which the softmax residual/Fisher are the exact
612    // derivatives of `Σ_c y_c log p_c`, and the row-weight check the likelihood
613    // adapter consumes.
614    let n_obs = design.nrows();
615    let (y_rows, k) = y_one_hot.dim();
616    if y_rows != n_obs {
617        crate::bail_invalid_estim!(
618            "fit_penalized_multinomial: y rows {y_rows} ≠ design rows {n_obs}"
619        );
620    }
621    if k < 2 {
622        crate::bail_invalid_estim!(
623            "fit_penalized_multinomial: need at least 2 classes (got K={k})"
624        );
625    }
626    let m = k - 1;
627    if lambdas.len() != m {
628        crate::bail_invalid_estim!(
629            "fit_penalized_multinomial: lambdas length {} ≠ K-1 = {m}",
630            lambdas.len()
631        );
632    }
633    if let Some(fw) = fisher_w_override.as_ref() {
634        if fw.dim() != (n_obs, m, m) {
635            crate::bail_invalid_estim!(
636                "fit_penalized_multinomial: fisher_w_override shape {:?} ≠ (N, K-1, K-1) = ({n_obs}, {m}, {m})",
637                fw.dim()
638            );
639        }
640    }
641    if let Some(w) = row_weights.as_ref() {
642        if w.len() != n_obs {
643            crate::bail_invalid_estim!(
644                "fit_penalized_multinomial: row_weights length {} ≠ N = {n_obs}",
645                w.len()
646            );
647        }
648        for (i, &v) in w.iter().enumerate() {
649            if !(v.is_finite() && v >= 0.0) {
650                crate::bail_invalid_estim!(
651                    "fit_penalized_multinomial: row_weights[{i}] must be finite and ≥ 0 (got {v})"
652                );
653            }
654        }
655    }
656    validate_multinomial_simplex(y_one_hot, "fit_penalized_multinomial")?;
657
658    let p = design.ncols();
659    let resumed_newton_coefficients = match resume_from {
660        Some(checkpoint) if checkpoint.stage() == FixedLambdaSolverStage::MultinomialFirth => {
661            let coefficients = fixed_lambda_checkpoint_coefficients(
662                checkpoint,
663                FixedLambdaSolverStage::MultinomialFirth,
664                p,
665                m,
666            )?;
667            return fit_penalized_multinomial_firth_fallback(
668                design,
669                y_one_hot,
670                penalty,
671                lambdas,
672                row_weights,
673                max_iter,
674                tol,
675                Some(FirthResume {
676                    coefficients: coefficients.view(),
677                    completed_iterations: checkpoint.completed_iterations(),
678                }),
679            );
680        }
681        Some(checkpoint) => Some(fixed_lambda_checkpoint_coefficients(
682            checkpoint,
683            FixedLambdaSolverStage::MultinomialNewton,
684            p,
685            m,
686        )?),
687        None => None,
688    };
689    let vector_resume = resumed_newton_coefficients
690        .as_ref()
691        .map(|coefficients| VectorGlmResume {
692            coefficients: coefficients.view(),
693            completed_iterations: resume_from
694                .map(FixedLambdaCheckpoint::completed_iterations)
695                .unwrap_or(0),
696        });
697
698    // ────────────────────────── likelihood construction ───────────────────
699    let mut likelihood = MultinomialLogitLikelihood::with_classes(k)?;
700    if let Some(w) = row_weights.as_ref() {
701        likelihood = likelihood.with_row_weights(w.to_owned())?;
702    }
703
704    // ─────────────────── shared penalized vector-GLM solve ─────────────────
705    // The softmax Fisher block is dense across the `M = K − 1` active classes;
706    // the engine assembles the coupled `(P·M)×(P·M)` penalized Hessian, runs
707    // the damped Newton loop, and returns the converged `β̂` and `η = X β̂`.
708    let solve = fit_penalized_vector_glm(
709        PenalizedVectorGlmInputs {
710            design,
711            y: y_one_hot,
712            penalty,
713            lambdas,
714            fisher_w_override,
715            max_iter,
716            tol,
717            // #1587: production multinomial still uses the per-class Diagonal
718            // metric pending the REML per-class→per-term λ re-key that the
719            // reference-symmetric Centered metric requires (shared λ). The
720            // Centered engine path + its invariance proof land first.
721            class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Diagonal,
722            resume_from: vector_resume,
723        },
724        &likelihood,
725        "fit_penalized_multinomial",
726    )?;
727
728    let fit = match solve {
729        VectorGlmSolve::Converged(fit) => fit,
730        VectorGlmSolve::Stalled(stall) => {
731            return handle_multinomial_fixed_lambda_stall(
732                stall,
733                design,
734                y_one_hot,
735                penalty,
736                lambdas,
737                row_weights,
738                max_iter,
739                tol,
740            );
741        }
742    };
743
744    let fitted_probabilities = likelihood.probabilities(fit.eta.view());
745
746    Ok(MultinomialFitOutputs {
747        coefficients_active: fit.coefficients,
748        fitted_probabilities,
749        iterations: fit.iterations,
750        penalized_neg_log_likelihood: -fit.log_likelihood + fit.penalty_term,
751        deviance: -2.0 * fit.log_likelihood,
752        coefficient_covariance: fit.coefficient_covariance,
753    })
754}
755
756/// Resolve a budget-exhausted fixed-λ softmax Newton solve: either the
757/// separation lane (escalate to the Firth/Jeffreys proper-prior refit) or the
758/// typed non-convergence error. Never mints a fit from the stalled iterate.
759fn handle_multinomial_fixed_lambda_stall(
760    stall: crate::penalized_vector_glm::VectorGlmStall,
761    design: ArrayView2<'_, f64>,
762    y_one_hot: ArrayView2<'_, f64>,
763    penalty: ArrayView2<'_, f64>,
764    lambdas: ArrayView1<'_, f64>,
765    row_weights: Option<ArrayView1<'_, f64>>,
766    max_iter: usize,
767    tol: f64,
768) -> Result<MultinomialFitOutputs, EstimationError> {
769    let (max_abs_eta, row_index, active_class_index) = max_abs_eta_location(stall.eta.view());
770    if max_abs_eta >= MULTINOMIAL_SEPARATION_ETA_THRESHOLD {
771        // Perfect / quasi-perfect separation (#1854): the UNBIASED softmax MLE is
772        // not finite along `active_class_index`'s saturated logit direction, so
773        // the fixed-λ Newton above ran away (`|η| ≥ 25`, no convergence). A
774        // penalty-null direction `v` (`S v = 0`, e.g. an unpenalized intercept /
775        // linear-covariate column) under softmax saturation has
776        // `(XᵀWX + λS) v → 0` for EVERY λ, so no smoothing parameter can bound it
777        // — only a proper prior on that quotient-null subspace can. Rather than
778        // hard-erroring, engage the Firth/Jeffreys proper prior automatically
779        // (magic-by-default): the full-span `½ log|I(β)|` correction supplies the
780        // `O(1)` curvature that keeps the estimate finite on exactly those
781        // separated directions while leaving well-identified fits untouched. This
782        // reuses the same coupled joint-Newton Jeffreys machinery the formula
783        // REML path arms on separation evidence (see
784        // `fit_penalized_multinomial_formula`), only here at the caller's fixed λ.
785        // Engage the fallback, but never let an internal consistency panic in
786        // the coupled joint-Newton assembly (e.g. the #1395 logdet-collapse
787        // guard) escape as a process abort: convert any panic into the
788        // documented hard separation diagnostic, exactly as if the refit had
789        // returned Err. This mirrors the catch_unwind panic-to-typed-error
790        // boundary already used around the faer / cudarc entry points, and keeps
791        // the separation path no worse than the pre-#1854 clean error while the
792        // Firth refit is still being hardened.
793        let firth = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
794            fit_penalized_multinomial_firth_fallback(
795                design,
796                y_one_hot,
797                penalty,
798                lambdas,
799                row_weights,
800                max_iter,
801                tol,
802                Some(FirthResume {
803                    coefficients: stall.coefficients.view(),
804                    completed_iterations: 0,
805                }),
806            )
807        }));
808        match firth {
809            // SPEC: a fit object must only ever come from a converged
810            // optimization — the Firth fallback itself surfaces a
811            // budget-exhausted refit as the typed
812            // `FixedLambdaNewtonDidNotConverge`, which is forwarded verbatim so
813            // the caller sees which lane stalled and its evidence.
814            Ok(Ok(out)) => return Ok(out),
815            Ok(Err(err @ EstimationError::FixedLambdaNewtonDidNotConverge { .. })) => {
816                return Err(err);
817            }
818            // Firth refit errored, or an internal consistency guard panicked:
819            // fall back to the explicit hard separation diagnostic.
820            Ok(Err(_)) | Err(_) => {
821                return Err(EstimationError::MultinomialSeparationDetected {
822                    iteration: stall.iterations,
823                    max_abs_eta,
824                    active_class_index,
825                    row_index,
826                });
827            }
828        }
829    }
830
831    // SPEC: a fit object must only ever come from a converged optimization.
832    // A stall WITHOUT the separation fingerprint (|η| below the threshold —
833    // e.g. ill-conditioned data exhausting `max_iter`) is a typed error
834    // carrying its evidence, never an Ok(outputs) with a flag.
835    Err(stall.into_nonconvergence_error(
836        FixedLambdaSolverStage::MultinomialNewton,
837        "fit_penalized_multinomial (fixed-λ softmax damped Newton)",
838    )?)
839}
840
841/// Firth/Jeffreys-penalized multinomial refit engaged automatically when the
842/// unbiased softmax MLE separates (#1854).
843///
844/// The unbiased fixed-λ solve ([`fit_penalized_multinomial`]) runs away on
845/// (quasi-)separated data because the softmax likelihood has no finite mode along
846/// the saturated logit direction and the smoothing penalty `S` cannot bound a
847/// penalty-null direction (`S v = 0` ⇒ `(XᵀWX + λS) v → 0` for every λ). This
848/// refit arms the full-span Jeffreys/Firth proper prior `½ log|I(β)|` on the
849/// coupled joint softmax information, which supplies the `O(1)` curvature that
850/// bounds exactly those directions and keeps the estimate finite.
851///
852/// # The estimator
853///
854/// It maximizes the penalized Firth objective at the caller's *fixed* `λ`
855///
856/// ```text
857///   ℓ*(β) = Σ_n w_n Σ_c y_{nc} log p_{nc}
858///           − ½ Σ_a λ_a βₐᵀ S βₐ
859///           + ½ log det I(β)
860/// ```
861///
862/// where `I(β)` is the coupled `(P·M)×(P·M)` softmax Fisher information (block
863/// `(a,b)` is `Σ_n w_n (δ_{ab} p_{na} − p_{na} p_{nb}) x_n x_nᵀ`, block-ordered so
864/// `θ[a·P+i] = β[i,a]`) and `M = K−1` active classes carry the reference-coded
865/// logits (`η_{ref} ≡ 0`). The Jeffreys term `½ log det I(β)` is the standard
866/// Firth penalty: it diverges to `−∞` as any fitted probability approaches the
867/// simplex boundary (`I → 0`), so its maximizer is interior and finite on exactly
868/// the separated directions that defeat every smoothing `λ`.
869///
870/// # Why this fixed-λ solver rather than the outer-REML formula path
871///
872/// The direct entry ([`fit_penalized_multinomial`]) is a fixed-λ inner solve — it
873/// carries no outer smoothing selection — so the natural Firth engagement is a
874/// fixed-λ Firth Newton, not the formula path's outer-REML joint-Newton machinery
875/// (which is armed instead by [`fit_penalized_multinomial_formula`] on separation
876/// evidence). Solving the Firth objective directly here keeps the separation
877/// contract self-contained and independent of the shared trust-region/KKT
878/// certificate machinery.
879///
880/// # The iteration
881///
882/// A Fisher-scoring Newton on `ℓ*`: the ascent direction is
883/// `Δ = (I + Λ⊗S)⁻¹ U*`, where `U*` is the Firth-adjusted penalized score
884///
885/// ```text
886///   U*[(c,s)] = Σ_n w_n x_{ns} (y_{nc} − p_{nc})       (data score)
887///             − λ_c (S β_c)_s                           (smoothing penalty)
888///             + ½ Σ_n w_n x_{ns} h^c_n                  (Firth adjustment)
889/// ```
890///
891/// and the Firth adjustment uses `h^c_n = Σ_{a,b} G^c_{n,ab} Q_{n,ab}` with the
892/// per-row information "hat" `Q_{n,ab} = x_nᵀ [I⁻¹]_{(a,b)} x_n` and the softmax
893/// third-derivative tensor
894/// `G^c_{ab} = δ_{ab} p_a (δ_{ac} − p_c) − p_a p_b (δ_{ac} + δ_{bc} − 2 p_c)`.
895/// This `½ Σ tr(I⁻¹ ∂I/∂β)` is exactly `∇[½ log det I]` (finite-difference
896/// verified). Each step is globalized by backtracking on `ℓ*`, so a step that
897/// would push a probability to the boundary (making `I` non-PD) is rejected and
898/// the fit stays interior. Convergence is the Newton decrement `½ U*ᵀΔ`.
899fn fit_penalized_multinomial_firth_fallback(
900    design: ArrayView2<'_, f64>,
901    y_one_hot: ArrayView2<'_, f64>,
902    penalty: ArrayView2<'_, f64>,
903    lambdas: ArrayView1<'_, f64>,
904    row_weights: Option<ArrayView1<'_, f64>>,
905    max_iter: usize,
906    tol: f64,
907    resume_from: Option<FirthResume<'_>>,
908) -> Result<MultinomialFitOutputs, EstimationError> {
909    use faer::Side;
910    use gam_linalg::faer_ndarray::{
911        FaerArrayView, array1_to_col_matmut, array2_to_matmut, factorize_symmetricwith_fallback,
912    };
913    use gam_linalg::matrix::FactorizedSystem;
914
915    let n_obs = design.nrows();
916    let p = design.ncols();
917    let k = y_one_hot.ncols();
918    let m = k - 1;
919    let d = p * m;
920
921    // Local softmax likelihood mirroring the caller's row weights, used to map the
922    // fitted η back to probabilities.
923    let mut likelihood = MultinomialLogitLikelihood::with_classes(k)?;
924    if let Some(w) = row_weights.as_ref() {
925        likelihood = likelihood.with_row_weights(w.to_owned())?;
926    }
927    let weight = |row: usize| -> f64 { row_weights.as_ref().map_or(1.0, |w| w[row]) };
928
929    let tol_eff = if tol.is_finite() && tol > 0.0 {
930        tol
931    } else {
932        1e-8
933    };
934
935    // Probabilities (N, K), active classes 0..M then the pinned reference at M.
936    let probs_at = |beta: &Array2<f64>| -> Array2<f64> {
937        let eta = design.dot(beta);
938        likelihood.probabilities(eta.view())
939    };
940
941    // Coupled softmax Fisher information I (d×d), block-ordered θ[a·P+i] = β[i,a].
942    let assemble_info = |probs: &Array2<f64>| -> Array2<f64> {
943        let mut info = Array2::<f64>::zeros((d, d));
944        for row in 0..n_obs {
945            let w = weight(row);
946            if w == 0.0 {
947                continue;
948            }
949            for a in 0..m {
950                let pa = probs[[row, a]];
951                let ao = a * p;
952                for b in 0..m {
953                    let pb = probs[[row, b]];
954                    let wab = w * (if a == b { pa - pa * pb } else { -pa * pb });
955                    if wab == 0.0 {
956                        continue;
957                    }
958                    let bo = b * p;
959                    for i in 0..p {
960                        let xi = design[[row, i]];
961                        if xi == 0.0 {
962                            continue;
963                        }
964                        let cc = wab * xi;
965                        for j in 0..p {
966                            info[[ao + i, bo + j]] += cc * design[[row, j]];
967                        }
968                    }
969                }
970            }
971        }
972        info
973    };
974
975    // Factor a symmetric matrix (with escalating ridge only if it is not SPD) and
976    // return its inverse and log-determinant.
977    //
978    // The ridge ladder is a standard relative-jitter Cholesky recovery, not a
979    // tuned knob: (a) the base jitter is scaled to the matrix by `max_diag`
980    // (`max_diag · ε` with ε at the double-precision Cholesky floor ~1e-10) so it
981    // is invariant to the overall scale of the Fisher information, falling back
982    // to an absolute floor only when the diagonal is degenerate; (b) it is tried
983    // first at ridge 0 so an already-SPD matrix is factored unperturbed; (c) it
984    // grows geometrically (×4) to span the ~120 dB from the base jitter to O(1)
985    // in a bounded number of steps; (d) the attempt count is capped so a
986    // genuinely singular information (e.g. an exactly rank-deficient Fisher block)
987    // surfaces as an explicit error rather than an unbounded loop.
988    let invert_spd = |mat: &Array2<f64>,
989                      context: &str|
990     -> Result<(Array2<f64>, f64), EstimationError> {
991        let max_diag = (0..d).fold(0.0_f64, |acc, i| acc.max(mat[[i, i]].abs()));
992        let base = if max_diag.is_finite() && max_diag > 0.0 {
993            max_diag * 1e-10
994        } else {
995            1e-10
996        };
997        let mut ridge = 0.0_f64;
998        for _ in 0..=60 {
999            let mut ridged = mat.clone();
1000            if ridge > 0.0 {
1001                for i in 0..d {
1002                    ridged[[i, i]] += ridge;
1003                }
1004            }
1005            if let Ok(factor) =
1006                factorize_symmetricwith_fallback(FaerArrayView::new(&ridged).as_ref(), Side::Lower)
1007            {
1008                let logdet = factor.logdet();
1009                if logdet.is_finite() {
1010                    let mut rhs = Array2::<f64>::eye(d);
1011                    {
1012                        let v = array2_to_matmut(&mut rhs);
1013                        factor.solve_in_place(v);
1014                    }
1015                    if rhs.iter().all(|x| x.is_finite()) {
1016                        let mut inv = Array2::<f64>::zeros((d, d));
1017                        for i in 0..d {
1018                            for j in 0..d {
1019                                inv[[i, j]] = 0.5 * (rhs[[i, j]] + rhs[[j, i]]);
1020                            }
1021                        }
1022                        return Ok((inv, logdet));
1023                    }
1024                }
1025            }
1026            ridge = if ridge > 0.0 { ridge * 4.0 } else { base };
1027        }
1028        Err(EstimationError::InvalidInput(format!(
1029            "multinomial Firth fallback: {context} not invertible (max_diag={max_diag:.3e})"
1030        )))
1031    };
1032
1033    // SPD log-determinant only (no ridge): used by the backtracking line search to
1034    // reject any candidate that pushes a fitted probability to the simplex
1035    // boundary (where I loses positive-definiteness and the Firth term → −∞).
1036    let spd_logdet = |mat: &Array2<f64>| -> Option<f64> {
1037        factorize_symmetricwith_fallback(FaerArrayView::new(mat).as_ref(), Side::Lower)
1038            .ok()
1039            .map(|factor| factor.logdet())
1040            .filter(|ld| ld.is_finite())
1041    };
1042
1043    // Penalized Firth objective ℓ* (MAXIMIZED), given probabilities, β, and the
1044    // precomputed log det I(β).
1045    let objective = |probs: &Array2<f64>, beta: &Array2<f64>, logdet_info: f64| -> f64 {
1046        let mut ll = 0.0_f64;
1047        for row in 0..n_obs {
1048            let w = weight(row);
1049            if w == 0.0 {
1050                continue;
1051            }
1052            for c in 0..k {
1053                let ycn = y_one_hot[[row, c]];
1054                if ycn != 0.0 {
1055                    ll += w * ycn * probs[[row, c]].max(f64::MIN_POSITIVE).ln();
1056                }
1057            }
1058        }
1059        let mut pen = 0.0_f64;
1060        for a in 0..m {
1061            let la = lambdas[a];
1062            if la != 0.0 {
1063                let bcol = beta.column(a);
1064                let sbeta = penalty.dot(&bcol);
1065                pen += 0.5 * la * bcol.dot(&sbeta);
1066            }
1067        }
1068        ll - pen + 0.5 * logdet_info
1069    };
1070
1071    // Firth-adjusted penalized score U* (length d, block-ordered).
1072    let firth_score =
1073        |probs: &Array2<f64>, beta: &Array2<f64>, iinv: &Array2<f64>| -> Array1<f64> {
1074            let mut u = Array1::<f64>::zeros(d);
1075            let mut xn = vec![0.0_f64; p];
1076            let mut pa = vec![0.0_f64; m];
1077            let mut q = vec![0.0_f64; m * m];
1078            for row in 0..n_obs {
1079                let w = weight(row);
1080                if w == 0.0 {
1081                    continue;
1082                }
1083                for i in 0..p {
1084                    xn[i] = design[[row, i]];
1085                }
1086                for a in 0..m {
1087                    pa[a] = probs[[row, a]];
1088                }
1089                // Data score: U[(a,i)] += w x_{ni} (y_{na} − p_{na}).
1090                for a in 0..m {
1091                    let resid = y_one_hot[[row, a]] - pa[a];
1092                    let ao = a * p;
1093                    for i in 0..p {
1094                        u[ao + i] += w * xn[i] * resid;
1095                    }
1096                }
1097                // Per-row information hat Q_{ab} = x_nᵀ [I⁻¹]_{(a,b)} x_n.
1098                for a in 0..m {
1099                    let ao = a * p;
1100                    for b in 0..m {
1101                        let bo = b * p;
1102                        let mut s = 0.0_f64;
1103                        for i in 0..p {
1104                            let xi = xn[i];
1105                            if xi == 0.0 {
1106                                continue;
1107                            }
1108                            let mut inner = 0.0_f64;
1109                            for j in 0..p {
1110                                inner += iinv[[ao + i, bo + j]] * xn[j];
1111                            }
1112                            s += xi * inner;
1113                        }
1114                        q[a * m + b] = s;
1115                    }
1116                }
1117                // Firth adjustment: U[(c,s)] += ½ w x_{ns} h^c_n.
1118                for c in 0..m {
1119                    let pc = pa[c];
1120                    let mut h = 0.0_f64;
1121                    for a in 0..m {
1122                        for b in 0..m {
1123                            let dab = if a == b { 1.0 } else { 0.0 };
1124                            let dac = if a == c { 1.0 } else { 0.0 };
1125                            let dbc = if b == c { 1.0 } else { 0.0 };
1126                            let g =
1127                                dab * pa[a] * (dac - pc) - pa[a] * pa[b] * (dac + dbc - 2.0 * pc);
1128                            h += g * q[a * m + b];
1129                        }
1130                    }
1131                    let co = c * p;
1132                    for s in 0..p {
1133                        u[co + s] += 0.5 * w * h * xn[s];
1134                    }
1135                }
1136            }
1137            // Smoothing penalty gradient: U[(a,i)] −= λ_a (S β_a)_i.
1138            for a in 0..m {
1139                let la = lambdas[a];
1140                if la != 0.0 {
1141                    let sbeta = penalty.dot(&beta.column(a));
1142                    let ao = a * p;
1143                    for i in 0..p {
1144                        u[ao + i] -= la * sbeta[i];
1145                    }
1146                }
1147            }
1148            u
1149        };
1150
1151    // Penalized Hessian H = I + blockdiag_a(λ_a S) (positive definite).
1152    let penalized_hessian = |info: &Array2<f64>| -> Array2<f64> {
1153        let mut h = info.clone();
1154        for a in 0..m {
1155            let la = lambdas[a];
1156            if la != 0.0 {
1157                let ao = a * p;
1158                for i in 0..p {
1159                    for j in 0..p {
1160                        h[[ao + i, ao + j]] += la * penalty[[i, j]];
1161                    }
1162                }
1163            }
1164        }
1165        h
1166    };
1167
1168    // Solve H Δ = U* for the SPD penalized Hessian, ridge-escalating only on
1169    // factorization failure. Same relative-jitter Cholesky-recovery ladder as
1170    // `invert_spd` above (see its comment for the rationale); the base jitter is
1171    // one decade tighter (`max_diag · 1e-12`) because the penalized Hessian
1172    // solved here is better conditioned than the Fisher information inverted
1173    // there, so a smaller perturbation suffices before escalating.
1174    let solve_spd = |mat: &Array2<f64>,
1175                     rhs: &Array1<f64>|
1176     -> Result<Array1<f64>, EstimationError> {
1177        let max_diag = (0..d).fold(0.0_f64, |acc, i| acc.max(mat[[i, i]].abs()));
1178        let base = if max_diag.is_finite() && max_diag > 0.0 {
1179            max_diag * 1e-12
1180        } else {
1181            1e-12
1182        };
1183        let mut ridge = 0.0_f64;
1184        for _ in 0..=60 {
1185            let mut ridged = mat.clone();
1186            if ridge > 0.0 {
1187                for i in 0..d {
1188                    ridged[[i, i]] += ridge;
1189                }
1190            }
1191            if let Ok(factor) =
1192                factorize_symmetricwith_fallback(FaerArrayView::new(&ridged).as_ref(), Side::Lower)
1193            {
1194                let mut sol = rhs.clone();
1195                {
1196                    let v = array1_to_col_matmut(&mut sol);
1197                    factor.solve_in_place(v);
1198                }
1199                if sol.iter().all(|x| x.is_finite()) {
1200                    return Ok(sol);
1201                }
1202            }
1203            ridge = if ridge > 0.0 { ridge * 4.0 } else { base };
1204        }
1205        Err(EstimationError::InvalidInput(
1206            "multinomial Firth fallback: penalized Hessian solve failed".to_string(),
1207        ))
1208    };
1209
1210    // ─────────────────────────── Firth Newton loop ────────────────────────────
1211    let (mut beta, completed_iterations) = match resume_from {
1212        Some(resume) => {
1213            if resume.coefficients.dim() != (p, m) {
1214                crate::bail_invalid_estim!(
1215                    "multinomial Firth resume coefficient shape {:?} does not match P x (K-1) = {p}x{m}",
1216                    resume.coefficients.dim(),
1217                );
1218            }
1219            (resume.coefficients.to_owned(), resume.completed_iterations)
1220        }
1221        None => (Array2::<f64>::zeros((p, m)), 0),
1222    };
1223    let mut iterations = completed_iterations;
1224    let mut stall_reason = FixedLambdaStallReason::IterationBudgetExhausted;
1225    let mut small_step_reached = false;
1226    for it in 0..max_iter {
1227        iterations = completed_iterations.checked_add(it + 1).ok_or_else(|| {
1228            EstimationError::InvalidInput(
1229                "multinomial Firth resume iteration count overflowed usize".to_string(),
1230            )
1231        })?;
1232        let probs = probs_at(&beta);
1233        let info = assemble_info(&probs);
1234        let (iinv, logdet_info) = invert_spd(&info, "Fisher information")?;
1235        let u = firth_score(&probs, &beta, &iinv);
1236        let hmat = penalized_hessian(&info);
1237        let step_vec = solve_spd(&hmat, &u)?;
1238
1239        // Newton decrement ½ U*ᵀ H⁻¹ U* = ½ U*ᵀ Δ (≥ 0, scale-aware stop).
1240        let decrement = u.dot(&step_vec);
1241        if 0.5 * decrement.abs() < tol_eff {
1242            break;
1243        }
1244
1245        // Δ as (P, M): delta[i, a] = step_vec[a·P + i].
1246        let mut delta = Array2::<f64>::zeros((p, m));
1247        for a in 0..m {
1248            let ao = a * p;
1249            for i in 0..p {
1250                delta[[i, a]] = step_vec[ao + i];
1251            }
1252        }
1253
1254        // Backtracking line search on ℓ* (ascent) via the shared `opt`
1255        // primitive: t₀ = 1, halving up to 60 trials. A candidate whose expected
1256        // information `I` is not SPD (boundary) is an INVALID trial (`Ok(None)`),
1257        // so the search contracts without consulting the acceptance test, keeping
1258        // the iterate interior. The ascent predicate `o1 ≥ o0 − 1e-12` is inlined
1259        // verbatim, so the accepted step is bit-for-bit the hand-rolled loop's.
1260        let o0 = objective(&probs, &beta, logdet_info);
1261        let accepted_step = match backtracking_line_search::<_, Infallible>(
1262            BacktrackConfig::default(),
1263            |step| {
1264                let cand = &beta + &(&delta * step);
1265                let cand_probs = probs_at(&cand);
1266                let cand_info = assemble_info(&cand_probs);
1267                Ok(spd_logdet(&cand_info)
1268                    .map(|cand_logdet| (objective(&cand_probs, &cand, cand_logdet), cand)))
1269            },
1270            |_step, o1| o1 >= o0 - 1e-12,
1271        ) {
1272            Ok(result) => result,
1273            Err(never) => match never {},
1274        };
1275        let Some(accepted_step) = accepted_step else {
1276            // Backtracking exhausted 60 halvings without an admissible ascent
1277            // step. This is convergence ONLY if the iterate is already first-order
1278            // stationary; a line-search stall at a non-stationary point is a
1279            // solver failure and must be reported as such, never papered over as
1280            // `converged = true` (#2066 — SPEC: do not report a non-converged
1281            // iterate as success).
1282            //
1283            // The verdict is the loop's OWN stationarity test — the Newton
1284            // decrement `½·Uᵀ H⁻¹ U` against `tol_eff`, the same criterion the top
1285            // of the loop uses to break as converged. A true interior mode never
1286            // reaches this branch: an infinitesimal step (`step → 0`) leaves the
1287            // iterate SPD with `o1 ≈ o0`, so it is accepted; a numerically flat
1288            // mode is caught by the `max_step` test below after that accepted
1289            // tiny step. Reaching here therefore means Newton still sees a
1290            // meaningful ascent direction it cannot realize (boundary / near-
1291            // singular Fisher information), i.e. a genuine stall → not converged.
1292            stall_reason = FixedLambdaStallReason::LineSearchExhausted;
1293            break;
1294        };
1295
1296        let step = accepted_step.step;
1297        beta = accepted_step.payload;
1298        let max_step = step * delta.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1299        let scale = 1.0 + beta.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1300        if max_step < tol_eff * scale {
1301            small_step_reached = true;
1302            break;
1303        }
1304    }
1305
1306    // ─────────────────────────── final quantities ─────────────────────────────
1307    for (idx, &v) in beta.iter().enumerate() {
1308        if !v.is_finite() {
1309            crate::bail_invalid_estim!(
1310                "multinomial Firth fallback: non-finite coefficient at flat index {idx} = {v}"
1311            );
1312        }
1313    }
1314    let coefficients_active = beta;
1315
1316    let mut log_likelihood = 0.0_f64;
1317    let probs = probs_at(&coefficients_active);
1318    for row in 0..n_obs {
1319        let w = weight(row);
1320        for c in 0..k {
1321            let ycn = y_one_hot[[row, c]];
1322            if ycn != 0.0 {
1323                log_likelihood += w * ycn * probs[[row, c]].max(f64::MIN_POSITIVE).ln();
1324            }
1325        }
1326    }
1327
1328    let mut penalty_term = 0.0_f64;
1329    for a in 0..m {
1330        let beta_col = coefficients_active.column(a);
1331        let sbeta = penalty.dot(&beta_col);
1332        penalty_term += 0.5 * lambdas[a] * beta_col.dot(&sbeta);
1333    }
1334
1335    // Recompute the Firth score and Newton decrement AT the final accepted
1336    // iterate. A tiny backtracked coefficient step is not itself stationarity:
1337    // only this fresh first-order certificate may authorize construction of a
1338    // fit or its covariance.
1339    let info = assemble_info(&probs);
1340    let (information_inverse, final_logdet_info) = invert_spd(&info, "final Fisher information")?;
1341    let final_score = firth_score(&probs, &coefficients_active, &information_inverse);
1342    let hmat = penalized_hessian(&info);
1343    let final_step = solve_spd(&hmat, &final_score)?;
1344    let final_decrement = 0.5 * final_score.dot(&final_step).abs();
1345    if !(final_decrement.is_finite() && final_decrement < tol_eff) {
1346        if small_step_reached {
1347            stall_reason = FixedLambdaStallReason::StationarityCertificateFailed;
1348        }
1349        // SPEC: a fit object must only ever come from a converged optimization.
1350        // A Firth refit that exhausted its budget (or stalled its line search at
1351        // a non-stationary point) is the typed error carrying its evidence — the
1352        // covariance below is never computed for an uncertified iterate.
1353        let checkpoint = FixedLambdaCheckpoint::new(
1354            FixedLambdaSolverStage::MultinomialFirth,
1355            coefficients_active.iter().copied().collect(),
1356            p,
1357            m,
1358            iterations,
1359        )
1360        .map_err(|reason| {
1361            EstimationError::InvalidInput(format!(
1362                "multinomial Firth fallback produced an invalid internal checkpoint: {reason}"
1363            ))
1364        })?;
1365        return Err(EstimationError::FixedLambdaNewtonDidNotConverge {
1366            context: "fit_penalized_multinomial (Firth/Jeffreys separation refit)".to_string(),
1367            reason: stall_reason,
1368            objective_value: -objective(&probs, &coefficients_active, final_logdet_info),
1369            stationarity: FixedLambdaStationarityEvidence {
1370                kind: FixedLambdaResidualKind::NewtonDecrement,
1371                residual: final_decrement,
1372                bound: tol_eff,
1373            },
1374            checkpoint,
1375        });
1376    }
1377
1378    // Laplace covariance H⁻¹ at the converged mode (block-ordered θ[a·P+i]).
1379    // A covariance that cannot be factored at a certified mode is a hard error,
1380    // never a silent zero matrix (a zero covariance is a false certainty claim).
1381    let (coefficient_covariance, _) = invert_spd(&hmat, "penalized Hessian covariance")?;
1382
1383    Ok(MultinomialFitOutputs {
1384        coefficients_active,
1385        fitted_probabilities: probs,
1386        iterations,
1387        penalized_neg_log_likelihood: -log_likelihood + penalty_term,
1388        deviance: -2.0 * log_likelihood,
1389        coefficient_covariance,
1390    })
1391}
1392
1393// ---------------------------------------------------------------------------
1394// Formula-driven multinomial pipeline
1395// ---------------------------------------------------------------------------
1396//
1397// Slice A of the multinomial integration: a single public entry that takes
1398// a parsed `EncodedDataset`, a Wilkinson-style formula, and a uniform initial
1399// smoothing parameter, then runs the full
1400//
1401//     parse → termspec → design (X, S blocks) → one-hot Y → REML λ-selection
1402//
1403// pipeline. `fit_penalized_multinomial_formula` drives the outer REML/LAML
1404// loop (via the custom-family path) to select an independent λ per (class,
1405// term); `init_lambda` (default 1.0) is only the warm-start seed for every
1406// block. The reference class is the last level of the categorical response
1407// column as recorded in the dataset schema.
1408
1409/// Saved-model payload for a multinomial fit driven by a Wilkinson formula.
1410///
1411/// This is what the FFI returns to Python. It carries everything the Python
1412/// `MultinomialModel.predict` path needs to evaluate `softmax(X_new · β)` on
1413/// fresh data using the *training* basis / penalty structure (no refit on
1414/// predict, no re-derivation of class levels).
1415#[derive(Debug, Clone, Serialize, Deserialize)]
1416#[serde(deny_unknown_fields)]
1417pub struct MultinomialSavedModel {
1418    /// The training formula, verbatim. Stored so Python's `summary()` and
1419    /// any round-trip persistence path can echo what was fit.
1420    pub formula: String,
1421    /// Names of the *training* response levels in canonical order. The last
1422    /// entry is the reference class (η = 0); the first `K - 1` carry the
1423    /// active linear-predictor blocks. Class permutations are forbidden:
1424    /// this list is fixed at fit time and predictions emit columns in the
1425    /// same order.
1426    pub class_levels: Vec<String>,
1427    /// Index of the reference class within `class_levels` — currently always
1428    /// `class_levels.len() - 1`, exposed as a field so future "user-pinned
1429    /// reference" gauges (e.g. `family='multinomial', reference='setosa'`)
1430    /// can land without changing the on-disk shape.
1431    pub reference_class_index: usize,
1432    /// Resolved term-collection spec used to build `X` at fit time. Replayed
1433    /// on predict via [`gam_terms::smooth::build_term_collection_design`].
1434    pub resolved_termspec: TermCollectionSpec,
1435    /// Active-class coefficient block, shape `(P, K-1)`. Column `a` is the
1436    /// coefficient vector for class `class_levels[a]`. Stored flat in
1437    /// row-major order to keep the serde payload self-describing.
1438    pub coefficients_flat: Vec<f64>,
1439    /// `P` — coefficient count per active class. Matches the column count of
1440    /// the design matrix the saved `resolved_termspec` produces.
1441    pub p_per_class: usize,
1442    /// Number of active classes (`K - 1`).
1443    pub n_active_classes: usize,
1444    /// Original training column headers, in dataset-column order. Needed at
1445    /// predict time so the FFI can align a fresh `Dataset` to the training
1446    /// schema before evaluating the basis.
1447    pub training_headers: Vec<String>,
1448    /// Container type of the training table. `"unknown"` is the explicit value
1449    /// for Rust/CLI callers without a typed table container; the field is always
1450    /// present so persistence never invents presentation state while loading.
1451    pub training_table_kind: String,
1452    /// REML/LAML-selected smoothing parameters, one per `(active class, smooth
1453    /// term)`, flattened in block-major order: all of class 0's per-term λ,
1454    /// then class 1's, and so on. Per-term penalties (#561) mean each active
1455    /// class block selects an *independent* λ for every smooth term, so this
1456    /// vector has length `Σ_a (#terms in class a)` = `(K − 1) · #terms`. Use
1457    /// [`MultinomialSavedModel::lambdas_per_block`] to segment it by class. An
1458    /// unpenalized model (no smooth terms) yields an empty vector.
1459    pub lambdas: Vec<f64>,
1460    /// Number of smoothing parameters (smooth terms) in each active class
1461    /// block, parallel to `class_levels[0..K-1]`. Segments the flat `lambdas`
1462    /// vector: class `a`'s λ are `lambdas[Σ_{b<a} lambdas_per_block[b] ..][..
1463    /// lambdas_per_block[a]]`. Every entry is identical in the shared-design
1464    /// architecture (all classes share the same term structure), but it is
1465    /// stored explicitly so consumers never have to assume that.
1466    pub lambdas_per_block: Vec<usize>,
1467    /// Newton iterations executed; recorded for the summary report.
1468    pub iterations: usize,
1469    /// Penalized negative log-likelihood at the returned `β̂`.
1470    pub penalized_neg_log_likelihood: f64,
1471    /// Unpenalized deviance `−2 log L(β̂)`.
1472    pub deviance: f64,
1473    /// Per-active-class effective degrees of freedom (hat-matrix trace),
1474    /// length `K - 1`. Populated when the REML driver reports an
1475    /// inference block; falls back to `None` for the legacy fixed-λ path.
1476    #[serde(default)]
1477    pub edf_per_class: Option<Vec<f64>>,
1478    /// Per-PENALTY effective degrees of freedom, one entry per smoothing
1479    /// parameter (length `== lambdas.len()`), aligned block-major with the flat
1480    /// [`Self::lambdas`] / [`Self::lambdas_per_block`] layout. Each entry is the
1481    /// penalty-block trace EDF `rank(S_k) − λ_k·tr(H⁻¹ S_k)`, clamped to
1482    /// `[0, rank(S_k)]`. This is the per-(class, term, penalty) resolution that
1483    /// the per-class [`Self::edf_per_class`] SUM deliberately hides: only the
1484    /// per-penalty vector reveals whether an individual smooth collapsed onto its
1485    /// polynomial null space (its wiggliness λ driven to the λ-cap), which a
1486    /// per-class total cannot show. Populated whenever the REML driver reports an
1487    /// inference block; `None` on the legacy fixed-λ path or when the trace
1488    /// channel is mis-shaped. Unlike `edf_per_class`, the entries do NOT sum to
1489    /// the model EDF when several penalties share one coefficient range (a
1490    /// double-penalty smooth has `Σ_k rank(S_k) > p_per_class`).
1491    #[serde(default)]
1492    pub edf_per_penalty: Option<Vec<f64>>,
1493    /// Joint posterior coefficient covariance `H⁻¹` (#1101), block-ordered to
1494    /// match the stacked active-class coefficient vector `β = [β_0; …; β_{K-2}]`
1495    /// (class `a`'s `P` coefficients occupy rows/cols `a·P .. (a+1)·P`). This is
1496    /// the Laplace covariance the REML driver already computes from the factored
1497    /// penalized Hessian; storing it makes posterior-mean prediction and its
1498    /// integrated uncertainty well-defined. Flattened row-major over the
1499    /// `(P·M)×(P·M)` matrix. This is required by the versioned persistence
1500    /// schema: a payload without covariance is not a usable multinomial model.
1501    pub coefficient_covariance_flat: Vec<f64>,
1502    /// Joint coefficient-space influence matrix `F = H⁻¹ X'WX` (#1101),
1503    /// block-ordered identically to [`Self::coefficient_covariance_flat`].
1504    /// Its per-term diagonal block trace is the term's effective degrees of
1505    /// freedom and its `tr(F_jj)²/tr(F_jj²)` the Wood reference d.f., feeding
1506    /// the rank-truncated Wald smooth-term test in `summary()`. Flattened
1507    /// row-major over the `(P·M)×(P·M)` matrix. `None` when unavailable.
1508    #[serde(default)]
1509    pub coefficient_influence_flat: Option<Vec<f64>>,
1510    /// Per-(active class, smooth term) coefficient column range and unpenalized
1511    /// nullspace dimension within the `P`-wide class block (#1101). Parallel to
1512    /// the smooth terms the design produced; replicated across classes by the
1513    /// shared-design architecture. Drives the Wald smooth-term table in
1514    /// `summary()`. Empty for a wholly parametric (no-smooth) model.
1515    #[serde(default)]
1516    pub smooth_term_spans: Vec<MultinomialSmoothTermSpan>,
1517    /// One descriptive label per *penalty component* within a single active-class
1518    /// block, parallel to that block's λ slice (i.e. length
1519    /// `lambdas_per_block[0]`). The Marra–Wood double penalty (and tensor /
1520    /// operator smooths) emit **more than one** penalty component — hence more
1521    /// than one λ — per smooth term, so this is NOT 1:1 with
1522    /// [`Self::smooth_term_spans`]: a single `s(x)` term contributes a primary
1523    /// wiggliness λ labelled `s(x)` and a null-space shrinkage λ labelled
1524    /// `s(x) [null space]`. The summary renderer pairs `lambdas` with these
1525    /// labels component-for-component so no λ is ever dropped (#1544). Built from
1526    /// the per-component term name + penalty role at fit time; empty only for a
1527    /// wholly parametric model.
1528    pub lambda_labels: Vec<String>,
1529}
1530
1531/// One smooth term's coefficient span within a class block, plus its
1532/// unpenalized nullspace dimension and a display label (#1101). The Wald
1533/// smooth-significance test in `summary()` slices the joint covariance /
1534/// influence at `a·P + col_start .. a·P + col_end` for active class `a`.
1535#[derive(Debug, Clone, Serialize, Deserialize)]
1536pub struct MultinomialSmoothTermSpan {
1537    /// Human-readable term label (the smooth's formula token), for the table.
1538    pub label: String,
1539    /// Start column of the term within the per-class `P`-wide coefficient block.
1540    pub col_start: usize,
1541    /// End column (exclusive) of the term within the per-class block.
1542    pub col_end: usize,
1543    /// Leading unpenalized (polynomial nullspace) dimension within the term.
1544    pub nullspace_dim: usize,
1545}
1546
1547/// Descriptive label for one penalty *component* (one λ) within a class block,
1548/// for the `summary()` per-class λ rollup (#1544). A smooth term can emit
1549/// several penalty components — the Marra–Wood double penalty splits `s(x)`
1550/// into a primary wiggliness penalty and a null-space shrinkage penalty, and
1551/// tensor / operator smooths emit a component per margin / differential
1552/// operator — each with its own independently-selected λ. The label is the
1553/// term name (from `PenaltyBlockInfo::termname`) plus a role suffix derived
1554/// from the penalty's [`PenaltySource`], so each λ in the summary names both
1555/// the term it smooths and the role it plays. `pen_idx` is the global penalty
1556/// index, used only as a last-resort fallback label.
1557fn penalty_component_label(info: Option<&PenaltyBlockInfo>, pen_idx: usize) -> String {
1558    use gam_terms::basis::PenaltySource;
1559    let term = info
1560        .and_then(|i| i.termname.clone())
1561        .unwrap_or_else(|| format!("s{pen_idx}"));
1562    let role = match info.map(|i| &i.penalty.source) {
1563        // The primary wiggliness penalty is the term's "main" λ; show the bare
1564        // term name so the common single-penalty case reads cleanly.
1565        Some(PenaltySource::Primary) | None => None,
1566        Some(PenaltySource::DoublePenaltyNullspace) => Some("null space".to_string()),
1567        Some(PenaltySource::OperatorMass) => Some("mass".to_string()),
1568        Some(PenaltySource::OperatorTension) => Some("tension".to_string()),
1569        Some(PenaltySource::OperatorStiffness) => Some("stiffness".to_string()),
1570        Some(PenaltySource::OperatorRelevance { axis }) => Some(format!("axis {axis}")),
1571        Some(PenaltySource::TensorMarginal { dim }) => Some(format!("margin {dim}")),
1572        Some(PenaltySource::TensorSeparable { penalized_margins }) => {
1573            Some(format!("separable {penalized_margins:?}"))
1574        }
1575        Some(PenaltySource::TensorGlobalRidge) => Some("ridge".to_string()),
1576        Some(PenaltySource::Other(s)) => Some(s.clone()),
1577    };
1578    match role {
1579        Some(role) => format!("{term} [{role}]"),
1580        None => term,
1581    }
1582}
1583
1584impl MultinomialSavedModel {
1585    pub fn validate(&self) -> Result<(), EstimationError> {
1586        if self.p_per_class == 0 || self.n_active_classes == 0 {
1587            crate::bail_invalid_estim!(
1588                "multinomial saved model dimensions must be nonzero, got P={} and K-1={}",
1589                self.p_per_class,
1590                self.n_active_classes,
1591            );
1592        }
1593        if self.class_levels.len() != self.n_active_classes + 1 {
1594            crate::bail_invalid_estim!(
1595                "multinomial saved model has {} class levels but K-1={}",
1596                self.class_levels.len(),
1597                self.n_active_classes,
1598            );
1599        }
1600        if self.reference_class_index != self.n_active_classes {
1601            crate::bail_invalid_estim!(
1602                "multinomial saved reference index {} does not equal the final class index {}",
1603                self.reference_class_index,
1604                self.n_active_classes,
1605            );
1606        }
1607        let d = self
1608            .p_per_class
1609            .checked_mul(self.n_active_classes)
1610            .ok_or_else(|| {
1611                EstimationError::InvalidInput(
1612                    "multinomial saved coefficient dimension overflowed usize".to_string(),
1613                )
1614            })?;
1615        if self.coefficients_flat.len() != d {
1616            crate::bail_invalid_estim!(
1617                "multinomial saved model has {} coefficient values, expected {d}",
1618                self.coefficients_flat.len(),
1619            );
1620        }
1621        if self.training_table_kind.trim().is_empty() {
1622            crate::bail_invalid_estim!(
1623                "multinomial saved model training_table_kind must be non-empty"
1624            );
1625        }
1626        if self.lambdas_per_block.len() != self.n_active_classes {
1627            crate::bail_invalid_estim!(
1628                "multinomial saved model has {} lambda blocks, expected {}",
1629                self.lambdas_per_block.len(),
1630                self.n_active_classes,
1631            );
1632        }
1633        let lambda_count = self
1634            .lambdas_per_block
1635            .iter()
1636            .try_fold(0usize, |total, &count| total.checked_add(count))
1637            .ok_or_else(|| {
1638                EstimationError::InvalidInput(
1639                    "multinomial saved lambda count overflowed usize".to_string(),
1640                )
1641            })?;
1642        if lambda_count != self.lambdas.len() {
1643            crate::bail_invalid_estim!(
1644                "multinomial saved model has {} lambdas but its blocks require {lambda_count}",
1645                self.lambdas.len(),
1646            );
1647        }
1648        if self
1649            .lambdas_per_block
1650            .iter()
1651            .any(|&count| count != self.lambda_labels.len())
1652        {
1653            crate::bail_invalid_estim!(
1654                "multinomial saved model has {} lambda labels but block sizes {:?}",
1655                self.lambda_labels.len(),
1656                self.lambdas_per_block,
1657            );
1658        }
1659        if self.lambda_labels.iter().any(|label| label.trim().is_empty()) {
1660            crate::bail_invalid_estim!("multinomial saved model lambda labels must be non-empty");
1661        }
1662        let covariance_len = d.checked_mul(d).ok_or_else(|| {
1663            EstimationError::InvalidInput(
1664                "multinomial saved covariance dimension overflowed usize".to_string(),
1665            )
1666        })?;
1667        if self.coefficient_covariance_flat.len() != covariance_len {
1668            crate::bail_invalid_estim!(
1669                "multinomial saved model has {} covariance values, expected {covariance_len}",
1670                self.coefficient_covariance_flat.len(),
1671            );
1672        }
1673        if let Some((index, value)) = self
1674            .coefficients_flat
1675            .iter()
1676            .chain(self.coefficient_covariance_flat.iter())
1677            .copied()
1678            .enumerate()
1679            .find(|(_, value)| !value.is_finite())
1680        {
1681            crate::bail_invalid_estim!(
1682                "multinomial saved numeric payload is non-finite at combined index {index}: {value}"
1683            );
1684        }
1685        Ok(())
1686    }
1687
1688    /// Active-class coefficient block as an `(P, K-1)` `ndarray` view.
1689    pub fn coefficients_active(&self) -> Result<Array2<f64>, EstimationError> {
1690        Array2::from_shape_vec(
1691            (self.p_per_class, self.n_active_classes),
1692            self.coefficients_flat.clone(),
1693        )
1694        .map_err(|error| {
1695            EstimationError::InvalidInput(format!(
1696                "multinomial saved coefficient payload is inconsistent with P x (K-1): {error}"
1697            ))
1698        })
1699    }
1700
1701    /// Reconstruct the joint posterior covariance `H⁻¹` as a `(P·M)×(P·M)`
1702    /// `ndarray`, block-ordered to match the stacked coefficient vector
1703    /// `θ[a·P + i] = β[i, a]` (#1101).
1704    pub fn coefficient_covariance(&self) -> Result<Array2<f64>, EstimationError> {
1705        let d = self
1706            .p_per_class
1707            .checked_mul(self.n_active_classes)
1708            .ok_or_else(|| {
1709                EstimationError::InvalidInput(
1710                    "multinomial saved covariance dimension overflowed usize".to_string(),
1711                )
1712            })?;
1713        Array2::from_shape_vec((d, d), self.coefficient_covariance_flat.clone()).map_err(|error| {
1714            EstimationError::InvalidInput(format!(
1715                "multinomial saved covariance payload is inconsistent with (P*(K-1)) squared: {error}"
1716            ))
1717        })
1718    }
1719
1720    /// Reconstruct the joint influence matrix `F = H⁻¹ X'WX` as a
1721    /// `(P·M)×(P·M)` `ndarray`, block-ordered like
1722    /// [`Self::coefficient_covariance`] (#1101). `None` when unavailable.
1723    pub fn coefficient_influence(&self) -> Option<Array2<f64>> {
1724        let d = self.p_per_class.checked_mul(self.n_active_classes)?;
1725        let flat = self.coefficient_influence_flat.as_ref()?;
1726        Array2::from_shape_vec((d, d), flat.clone()).ok()
1727    }
1728
1729    /// Default posterior-mean class probabilities. This integrates
1730    /// `softmax(eta)` under the per-row Gaussian predictor posterior rather than
1731    /// evaluating softmax at the coefficient mode.
1732    pub fn predict_probabilities(
1733        &self,
1734        x_new: ArrayView2<'_, f64>,
1735    ) -> Result<Array2<f64>, EstimationError> {
1736        self.predict_probabilities_with_se(x_new)
1737            .map(|(mean, _)| mean)
1738    }
1739
1740    /// Posterior-mean class probabilities and integrated marginal standard
1741    /// deviations at fresh design rows.
1742    pub fn predict_probabilities_with_se(
1743        &self,
1744        x_new: ArrayView2<'_, f64>,
1745    ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
1746        self.predict_probabilities_with_se_and_control(
1747            x_new,
1748            &MultinomialPosteriorIntegrationControl::default(),
1749        )
1750    }
1751
1752    pub fn predict_probabilities_with_se_and_control(
1753        &self,
1754        x_new: ArrayView2<'_, f64>,
1755        control: &MultinomialPosteriorIntegrationControl,
1756    ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
1757        let coefficients = self.coefficients_active()?;
1758        let covariance = self.coefficient_covariance()?;
1759        let moments = integrate_multinomial_design_moments(
1760            coefficients.view(),
1761            covariance.view(),
1762            x_new,
1763            control,
1764        )?;
1765        Ok((moments.class_mean, moments.class_standard_deviation))
1766    }
1767
1768    /// Wood (2013) rank-truncated Wald smooth-significance test per
1769    /// `(active class, smooth term)` (#1101), reusing the exact scalar-summary
1770    /// kernel [`gam_terms::inference::smooth_test::wood_smooth_test`]. For active
1771    /// class `a` and term span `[c0, c1)` within the class block, the global
1772    /// coefficient range is `a·P + c0 .. a·P + c1`; the joint covariance and
1773    /// influence are sliced there. The term EDF is the influence-block trace
1774    /// `tr(F_jj)` (when present) and the reference d.f. uses `tr(F_jj)²/tr(F_jj²)`,
1775    /// exactly as the scalar path. The multinomial softmax is a known-dispersion
1776    /// family, so the χ²_{ref_df} branch applies. Returns one row per
1777    /// `(class label, term label, edf, ref_df, statistic, p_value)`; empty when
1778    /// no covariance/smooth terms are available.
1779    pub fn smooth_significance(&self) -> Vec<MultinomialSmoothSignificance> {
1780        let mut out = Vec::new();
1781        let p = self.p_per_class;
1782        let m = self.n_active_classes;
1783        let Ok(cov) = self.coefficient_covariance() else {
1784            return out;
1785        };
1786        if self.smooth_term_spans.is_empty() {
1787            return out;
1788        }
1789        let Ok(beta) = self.coefficients_active() else {
1790            return out;
1791        };
1792        // Block-ordered θ = [β_0; …; β_{M-1}], θ[a·P + i] = β[i, a].
1793        let d = p * m;
1794        let mut theta = Array1::<f64>::zeros(d);
1795        for a in 0..m {
1796            for i in 0..p {
1797                theta[a * p + i] = beta[[i, a]];
1798            }
1799        }
1800        let influence = self.coefficient_influence();
1801        for a in 0..m {
1802            let class_label = self
1803                .class_levels
1804                .get(a)
1805                .cloned()
1806                .unwrap_or_else(|| format!("class{a}"));
1807            let base = a * p;
1808            for span in &self.smooth_term_spans {
1809                if span.col_end > p {
1810                    continue;
1811                }
1812                let start = base + span.col_start;
1813                let end = base + span.col_end;
1814                // Term EDF = tr(F_jj); without an influence matrix fall back to
1815                // the block coefficient count (full-rank Wald on the span).
1816                let block_len = (span.col_end - span.col_start) as f64;
1817                let edf = influence
1818                    .as_ref()
1819                    .map(|f| (start..end).map(|i| f[[i, i]]).sum::<f64>())
1820                    .filter(|v| v.is_finite() && *v > 0.0)
1821                    .unwrap_or(block_len);
1822                let result = gam_terms::inference::smooth_test::wood_smooth_test(
1823                    gam_terms::inference::smooth_test::SmoothTestInput {
1824                        beta: theta.view(),
1825                        covariance: &cov,
1826                        influence_matrix: influence.as_ref(),
1827                        whitening_gram: None,
1828                        coeff_range: start..end,
1829                        edf,
1830                        nullspace_dim: span.nullspace_dim,
1831                        residual_df: f64::INFINITY,
1832                        scale: gam_terms::inference::smooth_test::SmoothTestScale::Known,
1833                    },
1834                );
1835                if let Some(res) = result {
1836                    out.push(MultinomialSmoothSignificance {
1837                        class_label: class_label.clone(),
1838                        term_label: span.label.clone(),
1839                        edf,
1840                        ref_df: res.ref_df,
1841                        statistic: res.statistic,
1842                        p_value: res.p_value,
1843                    });
1844                }
1845            }
1846        }
1847        out
1848    }
1849
1850    /// Draw `n_draws` posterior-predictive replicate class assignments at fresh
1851    /// rows (#1101). Each draw independently samples every row's class from
1852    /// `Categorical(p_row)` with `p = E[softmax(eta) | data]`, so coefficient
1853    /// uncertainty is integrated before adding categorical observation noise.
1854    /// The returned `(n_draws, N)` matrix holds class
1855    /// INDICES `0..K`, aligned to [`Self::class_levels`]. The draw stream is a
1856    /// `StdRng` seeded by `seed`, so `(x_new, n_draws, seed)` reproduce
1857    /// bit-identically — the engine for posterior-predictive checks and
1858    /// simulation-based calibration. `x_new` must have `self.p_per_class`
1859    /// columns (built from the same `resolved_termspec` as fit time).
1860    pub fn sample_replicate_classes(
1861        &self,
1862        x_new: ArrayView2<'_, f64>,
1863        n_draws: usize,
1864        seed: u64,
1865    ) -> Result<Array2<u32>, EstimationError> {
1866        use rand::{RngExt, SeedableRng};
1867        let probs = self.predict_probabilities(x_new)?;
1868        let n = probs.nrows();
1869        let k = probs.ncols();
1870        let mut out = Array2::<u32>::zeros((n_draws, n));
1871        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
1872        for d in 0..n_draws {
1873            for row in 0..n {
1874                let u: f64 = rng.random::<f64>();
1875                // Inverse-CDF categorical draw over the K simplex weights.
1876                let mut acc = 0.0_f64;
1877                let mut chosen = k - 1; // numerical fallback = reference class
1878                for c in 0..k {
1879                    acc += probs[[row, c]];
1880                    if u < acc {
1881                        chosen = c;
1882                        break;
1883                    }
1884                }
1885                out[[d, row]] = chosen as u32;
1886            }
1887        }
1888        Ok(out)
1889    }
1890}
1891
1892/// On-disk `model_class` discriminator for a persisted multinomial model. Kept
1893/// as a single constant so every producer / consumer of the envelope agrees on
1894/// the tag without a scattered string literal.
1895pub const MULTINOMIAL_MODEL_CLASS: &str = "multinomial";
1896/// Exact multinomial persistence schema. Version 2 requires the canonical
1897/// per-component lambda labels and training-table provenance; successful
1898/// deserialization therefore yields a complete current model without repair.
1899pub const MULTINOMIAL_MODEL_FORMAT_VERSION: u32 = 2;
1900
1901/// Round-trip persistence envelope for a fitted multinomial model. The
1902/// `model_class` discriminator lets a loader tell a multinomial payload apart
1903/// from the scalar `FittedModel` JSON before deserialising the whole struct.
1904///
1905/// This is the single definition of the multinomial on-disk format, shared by
1906/// the Python FFI (`fit_multinomial_formula` / `predict_multinomial_formula`)
1907/// and the `gam` CLI (`gam fit --family multinomial` / `gam predict`), so a
1908/// model persisted by one surface loads in the other.
1909#[derive(Debug, Clone, Serialize, Deserialize)]
1910#[serde(deny_unknown_fields)]
1911pub struct MultinomialModelEnvelope {
1912    pub model_class: String,
1913    pub format_version: u32,
1914    pub saved: MultinomialSavedModel,
1915}
1916
1917impl MultinomialModelEnvelope {
1918    /// Wrap a fitted model with the canonical `model_class` tag.
1919    pub fn new(saved: MultinomialSavedModel) -> Result<Self, EstimationError> {
1920        saved.validate()?;
1921        Ok(Self {
1922            model_class: MULTINOMIAL_MODEL_CLASS.to_string(),
1923            format_version: MULTINOMIAL_MODEL_FORMAT_VERSION,
1924            saved,
1925        })
1926    }
1927
1928    /// Serialize to the canonical JSON byte payload.
1929    pub fn to_json_bytes(&self) -> Result<Vec<u8>, EstimationError> {
1930        self.saved.validate()?;
1931        serde_json::to_vec(self).map_err(|err| {
1932            EstimationError::InvalidInput(format!("failed to serialize multinomial model: {err}"))
1933        })
1934    }
1935
1936    /// Parse an envelope from JSON bytes, validating the `model_class`
1937    /// discriminator so a non-multinomial payload is rejected with a clear
1938    /// error rather than silently mis-predicted.
1939    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, EstimationError> {
1940        let envelope: Self = serde_json::from_slice(bytes).map_err(|err| {
1941            EstimationError::InvalidInput(format!("failed to deserialize multinomial model: {err}"))
1942        })?;
1943        if envelope.model_class != MULTINOMIAL_MODEL_CLASS {
1944            return Err(EstimationError::InvalidInput(format!(
1945                "multinomial model: model_class = {:?}, expected {MULTINOMIAL_MODEL_CLASS:?}",
1946                envelope.model_class
1947            )));
1948        }
1949        if envelope.format_version != MULTINOMIAL_MODEL_FORMAT_VERSION {
1950            return Err(EstimationError::InvalidInput(format!(
1951                "multinomial model: format_version = {}, expected {}",
1952                envelope.format_version, MULTINOMIAL_MODEL_FORMAT_VERSION,
1953            )));
1954        }
1955        envelope.saved.validate()?;
1956        Ok(envelope)
1957    }
1958}
1959
1960#[cfg(test)]
1961mod multinomial_persistence_contract_tests {
1962    use super::*;
1963
1964    #[test]
1965    fn unversioned_payload_is_rejected() {
1966        let payload = br#"{"model_class":"multinomial","saved":{}}"#;
1967        let error = MultinomialModelEnvelope::from_json_bytes(payload)
1968            .expect_err("unversioned multinomial persistence must not be guessed");
1969        assert!(
1970            error.to_string().contains("format_version"),
1971            "unexpected persistence error: {error}"
1972        );
1973    }
1974}
1975
1976/// One row of the multinomial smooth-significance table (#1101): the Wood
1977/// rank-truncated Wald test for one `(active class, smooth term)` pair.
1978#[derive(Debug, Clone)]
1979pub struct MultinomialSmoothSignificance {
1980    pub class_label: String,
1981    pub term_label: String,
1982    pub edf: f64,
1983    pub ref_df: f64,
1984    pub statistic: f64,
1985    pub p_value: f64,
1986}
1987
1988/// One-hot-encode the categorical response column and return both the
1989/// encoding and the captured level names. The level order matches the order
1990/// recorded in the dataset schema, which is the canonical (lexicographically
1991/// sorted) factor order produced by inferred-schema construction (#1319) — so
1992/// it is a deterministic function of the label *set*, independent of training
1993/// row order (no silent class permutation under a row shuffle), and matches the
1994/// R `factor()` / pandas `Categorical` convention.
1995fn one_hot_categorical_response(
1996    data: &EncodedDataset,
1997    y_col: usize,
1998    response_name: &str,
1999) -> Result<(Array2<f64>, Vec<String>), EstimationError> {
2000    let levels: Vec<String> = data
2001        .schema
2002        .columns
2003        .get(y_col)
2004        .map(|sc| sc.levels.clone())
2005        .unwrap_or_default();
2006    if levels.len() < 2 {
2007        crate::bail_invalid_estim!(
2008            "multinomial response '{response_name}' must have at least 2 categorical levels (got {})",
2009            levels.len()
2010        );
2011    }
2012    let n = data.values.nrows();
2013    let k = levels.len();
2014    let mut y_one_hot = Array2::<f64>::zeros((n, k));
2015    for row in 0..n {
2016        let encoded = data.values[[row, y_col]];
2017        if !encoded.is_finite() {
2018            crate::bail_invalid_estim!(
2019                "multinomial response '{response_name}' row {row} is non-finite ({encoded})"
2020            );
2021        }
2022        let class_idx = encoded.round() as i64;
2023        if class_idx < 0 || (class_idx as usize) >= k {
2024            crate::bail_invalid_estim!(
2025                "multinomial response '{response_name}' row {row} encoded as {encoded} \
2026                 is outside the level range 0..{k}"
2027            );
2028        }
2029        y_one_hot[[row, class_idx as usize]] = 1.0;
2030    }
2031    Ok((y_one_hot, levels))
2032}
2033
2034/// Build `(TermCollectionSpec, TermCollectionDesign)` from a formula against
2035/// a categorical-response dataset. Mirrors the early scaffolding inside
2036/// `materialize_standard` (response role resolution, geometry-aware spec
2037/// build) without touching the scalar-family resolution path — multinomial
2038/// owns its own response kind check.
2039fn build_formula_design_for_multinomial(
2040    formula: &str,
2041    data: &EncodedDataset,
2042    config: &FitConfig,
2043) -> Result<
2044    (
2045        TermCollectionSpec,
2046        TermCollectionDesign,
2047        usize,
2048        String,
2049        ResponseColumnKind,
2050    ),
2051    EstimationError,
2052> {
2053    let parsed = parse_formula(formula).map_err(|err| {
2054        EstimationError::InvalidInput(format!(
2055            "multinomial fit: failed to parse formula {formula:?}: {err}"
2056        ))
2057    })?;
2058    let col_map = data.column_map();
2059    let y_col = resolve_role_col(&col_map, &parsed.response, "response")
2060        .map_err(|err| EstimationError::InvalidInput(format!("multinomial fit: {err}")))?;
2061    let y_kind = crate::fit_orchestration::response_column_kind(data, y_col);
2062    let policy = resolved_resource_policy(config, ProblemHints::default());
2063    let mut inference_notes: Vec<String> = Vec::new();
2064    let spec = build_termspec_with_geometry_and_overrides(
2065        &parsed.terms,
2066        data,
2067        &col_map,
2068        &mut inference_notes,
2069        config.scale_dimensions,
2070        &policy,
2071        config.smooth_overrides.as_ref(),
2072        None,
2073    )
2074    .map_err(|err| {
2075        EstimationError::InvalidInput(format!("multinomial fit: build termspec: {err}"))
2076    })?;
2077    let design = build_term_collection_design(data.values.view(), &spec).map_err(|err| {
2078        EstimationError::InvalidInput(format!("multinomial fit: build design: {err}"))
2079    })?;
2080    Ok((spec, design, y_col, parsed.response, y_kind))
2081}
2082
2083fn scale_multinomial_formula_penalty(penalty: PenaltyMatrix, scale: f64) -> PenaltyMatrix {
2084    match penalty {
2085        PenaltyMatrix::Dense(matrix) => PenaltyMatrix::Dense(matrix.mapv(|v| v * scale)),
2086        PenaltyMatrix::KroneckerFactored { left, right } => PenaltyMatrix::KroneckerFactored {
2087            left: left.mapv(|v| v * scale),
2088            right,
2089        },
2090        PenaltyMatrix::Blockwise {
2091            local,
2092            col_range,
2093            total_dim,
2094        } => PenaltyMatrix::Blockwise {
2095            local: local.mapv(|v| v * scale),
2096            col_range,
2097            total_dim,
2098        },
2099        PenaltyMatrix::Labeled { label, inner } => PenaltyMatrix::Labeled {
2100            label,
2101            inner: Box::new(scale_multinomial_formula_penalty(*inner, scale)),
2102        },
2103        PenaltyMatrix::Fixed { log_lambda, inner } => PenaltyMatrix::Fixed {
2104            log_lambda,
2105            inner: Box::new(scale_multinomial_formula_penalty(*inner, scale)),
2106        },
2107    }
2108}
2109
2110/// Canonical typed inputs for the formula-driven multinomial fit
2111/// ([`fit_penalized_multinomial_formula`]).
2112///
2113/// Every frontend (Rust, CLI, Python FFI) builds this one request, so the
2114/// warm-start / outer-search defaults live here rather than being duplicated
2115/// per caller. `config` is the same canonical [`FitConfig`] the scalar formula
2116/// families consume: `weight_column` is resolved against the dataset and
2117/// honored as per-row case weights, and fields the softmax family cannot
2118/// consume (offsets, noise/log-slope formulas, manual Firth, frailty, ...) are
2119/// rejected with a typed error instead of being silently dropped.
2120#[derive(Clone, Copy)]
2121pub struct MultinomialFitRequest<'a> {
2122    pub data: &'a EncodedDataset,
2123    pub formula: &'a str,
2124    pub config: &'a FitConfig,
2125    /// Warm-start seed for every per-(class, term) smoothing parameter; λ is
2126    /// REML/LAML-selected, so this only seeds the outer search.
2127    pub init_lambda: f64,
2128    /// OUTER REML/LAML smoothing-parameter iteration budget.
2129    pub max_iter: usize,
2130    /// Requested accuracy; drives the inner joint-Newton KKT target (see the
2131    /// control-split note inside the fit).
2132    pub tol: f64,
2133}
2134
2135impl<'a> MultinomialFitRequest<'a> {
2136    /// The canonical production controls shared by the CLI and the Python FFI.
2137    pub fn new(data: &'a EncodedDataset, formula: &'a str, config: &'a FitConfig) -> Self {
2138        Self {
2139            data,
2140            formula,
2141            config,
2142            init_lambda: 1.0,
2143            max_iter: 50,
2144            tol: 1.0e-7,
2145        }
2146    }
2147}
2148
2149/// Reject canonical-config fields the softmax multinomial family cannot
2150/// consume. Silently dropping a requested offset / noise model / manual Firth
2151/// toggle would quietly change the estimand the caller asked for (SPEC 3), so
2152/// every unsupported field is a typed error shared by all frontends.
2153fn reject_unsupported_multinomial_config(config: &FitConfig) -> Result<(), EstimationError> {
2154    if config.offset_column.is_some() || config.noise_offset_column.is_some() {
2155        crate::bail_invalid_estim!(
2156            "multinomial fit does not support offset columns: a single offset column has no \
2157             canonical per-logit placement in the reference-coded softmax (offsets are per-class \
2158             linear-predictor quantities); remove the offset or fit per-class models"
2159        );
2160    }
2161    if config.noise_formula.is_some() {
2162        crate::bail_invalid_estim!(
2163            "noise_formula is not supported for the multinomial family: the softmax likelihood \
2164             has no dispersion predictor"
2165        );
2166    }
2167    if config.logslope_formula.is_some() || config.z_column.is_some() {
2168        crate::bail_invalid_estim!(
2169            "logslope_formula/z_column is not supported for the multinomial family"
2170        );
2171    }
2172    if config.transformation_normal {
2173        crate::bail_invalid_estim!(
2174            "transformation_normal conflicts with the multinomial family"
2175        );
2176    }
2177    if config.expectile_tau.is_some() {
2178        crate::bail_invalid_estim!("expectile_tau requires the expectile family");
2179    }
2180    if config.firth {
2181        crate::bail_invalid_estim!(
2182            "manual firth is not accepted for the multinomial family: the Firth/Jeffreys \
2183             separation stabilizer is armed automatically on separation evidence"
2184        );
2185    }
2186    if !matches!(
2187        config.frailty,
2188        crate::survival::lognormal_kernel::FrailtySpec::None
2189    ) {
2190        crate::bail_invalid_estim!("frailty is not supported for the multinomial family");
2191    }
2192    Ok(())
2193}
2194
2195/// Resolve the canonical `weight_column` into per-row case weights (`None` ⇒
2196/// uniform 1.0). Finiteness / non-negativity are enforced by
2197/// [`MultinomialFamily::new`], which owns the weight contract.
2198fn resolve_multinomial_row_weights(
2199    data: &EncodedDataset,
2200    config: &FitConfig,
2201) -> Result<Array1<f64>, EstimationError> {
2202    let Some(name) = config.weight_column.as_deref() else {
2203        return Ok(Array1::ones(data.values.nrows()));
2204    };
2205    let column = data.column_map().get(name).copied().ok_or_else(|| {
2206        EstimationError::InvalidInput(format!(
2207            "multinomial fit: weight column '{name}' not found in the dataset"
2208        ))
2209    })?;
2210    Ok(data.values.column(column).to_owned())
2211}
2212
2213/// Top-level formula-driven multinomial fit.
2214///
2215/// Routes through [`fit_custom_family_with_rho_prior`] so the per-active-class
2216/// smoothing parameters `λ_a` (one per class block, shared-penalty
2217/// architecture) are selected by the outer REML/LAML loop rather than pinned
2218/// by the caller. `init_lambda` survives as a warm-start hint that seeds
2219/// every block's `initial_log_lambdas`. `max_iter` / `tol` drive the OUTER
2220/// REML/LAML smoothing-parameter search (`outer_max_iter` / `outer_tol`); the
2221/// inner joint-Newton solve runs on the framework's principled production cycle
2222/// budget at the default KKT tolerance so an ill-conditioned, LM-damped
2223/// near-simplex-boundary solve can certify a stationary point instead of being
2224/// declared non-converged after only `max_iter` cycles (#715).
2225///
2226/// The Jeffreys/Firth proper prior is engaged CONDITIONALLY: attempt 1 runs
2227/// the unbiased penalized-REML criterion; only on separation evidence (a failed
2228/// solve or a non-finite logit; see [`multinomial_formula_separation_evidence`])
2229/// is the fit re-solved once with the full-span Firth prior armed, which bounds
2230/// the penalty-null directions no smoothing parameter can (`S v = 0` ⇒
2231/// `(H + S_λ) v = H v → 0` when the softmax likelihood has no finite mode).
2232///
2233/// The categorical response column is recognised via the dataset schema
2234/// (`ColumnKindTag::Categorical`); reference class = last level. Returns a
2235/// [`MultinomialSavedModel`] that can be serialised to bytes for the Python
2236/// wrapper or used in-process for `predict_probabilities`.
2237pub fn fit_penalized_multinomial_formula(
2238    request: &MultinomialFitRequest<'_>,
2239) -> Result<MultinomialSavedModel, EstimationError> {
2240    let MultinomialFitRequest {
2241        data,
2242        formula,
2243        config,
2244        init_lambda,
2245        max_iter,
2246        tol,
2247    } = *request;
2248    if !(init_lambda.is_finite() && init_lambda > 0.0) {
2249        crate::bail_invalid_estim!(
2250            "multinomial fit: init_lambda must be finite and > 0 (got {init_lambda})"
2251        );
2252    }
2253    reject_unsupported_multinomial_config(config)?;
2254    let (raw_spec, design, y_col, response_name, y_kind) =
2255        build_formula_design_for_multinomial(formula, data, config)?;
2256    // Freeze the data-derived basis state (B-spline knot vectors, by-factor
2257    // level sets, spatial centers, joint-null rotations, residualization
2258    // charts) from the fit design back onto the spec. The raw geometry spec
2259    // records only *which* columns and *what kind* of basis each smooth uses;
2260    // the actual column count and basis evaluation depend on quantities the
2261    // builder derives from the training data (knot placement, the distinct
2262    // by-factor levels, etc.). Saving the raw spec made predict re-derive those
2263    // from the (smaller, differently-distributed) predict frame, so the rebuilt
2264    // design had a different column count than the fitted one — the panic
2265    // "predict design has 42 cols, saved model expects 191" for an `s(x,
2266    // by=group)` smooth-by-factor model. Every other family's persistence path
2267    // freezes the spec the same way (see `freeze_term_collection_from_design`
2268    // call sites in `main_parts`); multinomial was the lone exception.
2269    let spec = freeze_term_collection_from_design(&raw_spec, &design)?;
2270    let class_levels = match y_kind {
2271        ResponseColumnKind::Categorical { levels } => levels,
2272        ResponseColumnKind::Binary => vec!["0".to_string(), "1".to_string()],
2273        ResponseColumnKind::Numeric => {
2274            crate::bail_invalid_estim!(
2275                "multinomial fit: response '{response_name}' is numeric, not categorical; \
2276                 use family='gaussian'/'binomial'/... or convert the column to a categorical type"
2277            );
2278        }
2279    };
2280    if data.column_kinds.get(y_col) == Some(&ColumnKindTag::Binary) {
2281        // Promote to a 2-level categorical for the multinomial driver; the
2282        // caller explicitly asked for multinomial, so we route through the
2283        // K-1 = 1 active-class softmax (equivalent math to logistic).
2284    } else if data.column_kinds.get(y_col) != Some(&ColumnKindTag::Categorical) {
2285        crate::bail_invalid_estim!(
2286            "multinomial fit: response '{response_name}' must be a categorical column \
2287             (got column kind {:?})",
2288            data.column_kinds.get(y_col)
2289        );
2290    }
2291    let (y_one_hot, _) = one_hot_categorical_response(data, y_col, &response_name)?;
2292    // Build the global X dense (the design is a DesignMatrix abstraction).
2293    let mut x_dense = design
2294        .design
2295        .try_to_dense_by_chunks("multinomial fit design")
2296        .map_err(EstimationError::InvalidInput)?;
2297
2298    // ── #715 real-data conditioning: standardize unpenalized parametric
2299    // columns. Raw-unit linear covariates (penguins `body_mass_g` ~ 4e3 grams)
2300    // inflate the joint Newton information by the squared column scale (a κ(H)
2301    // multiplier of ~s² ≈ 1e7 against the intercept), which is what turns the
2302    // near-separable LM-damped inner solve into a geometric grind that
2303    // exhausts its cycle budgets — the adapter-level face of "all REML startup
2304    // seeds rejected". Because these columns are UNPENALIZED (parametric terms
2305    // carry no default ridge, #749), the affine reparameterization
2306    // `x_j ↦ (x_j − m_j)/s_j` is EXACT for the whole criterion: the optimized
2307    // REML/LAML objective, the fitted η, the selected λ, and the separation
2308    // diagnostics are all invariant — only the conditioning of `H` changes.
2309    // Fitted coefficients are mapped back to raw units at repack below, so the
2310    // saved model and the (raw-design) predict path are untouched. Penalized
2311    // columns are left alone (a penalty makes the rescaling non-equivalent),
2312    // and nothing is touched when explicit coefficient bounds/constraints
2313    // exist (those are stated in raw units).
2314    let parametric_standardization: Vec<(usize, f64, f64)> =
2315        if design.coefficient_lower_bounds.is_some() || design.linear_constraints.is_some() {
2316            Vec::new()
2317        } else {
2318            let p_total = x_dense.ncols();
2319            let mut penalized = vec![false; p_total];
2320            for bp in &design.penalties {
2321                for col in bp.col_range.clone() {
2322                    if col < p_total {
2323                        penalized[col] = true;
2324                    }
2325                }
2326            }
2327            let has_intercept = !design.intercept_range.is_empty();
2328            let n_rows = x_dense.nrows().max(1) as f64;
2329            let mut standardized = Vec::new();
2330            for (_, range) in &design.linear_ranges {
2331                for col in range.clone() {
2332                    if col >= p_total || penalized[col] {
2333                        continue;
2334                    }
2335                    let column = x_dense.column(col);
2336                    let mean = column.sum() / n_rows;
2337                    let var = column.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n_rows;
2338                    let scale = var.sqrt();
2339                    // Skip near-constant or degenerate columns: no conditioning to
2340                    // be gained and the back-map would divide by ~0.
2341                    if !(scale.is_finite() && scale > 1e-8 * (mean.abs() + 1.0)) {
2342                        continue;
2343                    }
2344                    // Centering shifts mass onto the intercept; without one the
2345                    // shift is not representable, so scale only.
2346                    let center = if has_intercept { mean } else { 0.0 };
2347                    for v in x_dense.column_mut(col).iter_mut() {
2348                        *v = (*v - center) / scale;
2349                    }
2350                    standardized.push((col, center, scale));
2351                }
2352            }
2353            standardized
2354        };
2355    // Preserve the per-smooth-term penalty block structure (#561): each smooth
2356    // term `t` contributes its own `P × P` penalty component (`Blockwise` with
2357    // `total_dim = P`, the term's local `S_t` embedded at its `col_range`), and
2358    // every active class block receives the FULL list. The outer REML/LAML loop
2359    // then selects an independent smoothing parameter λ_{a,t} per (class, term),
2360    // matching mgcv/VGAM. Pre-summing the terms into one fused `S` (the prior
2361    // behaviour) forced a single λ per class that scales `Σ_t S_t`, so one
2362    // shared λ had to over-smooth a rough term while under-smoothing a smooth
2363    // one — biasing any multi-term class-probability surface.
2364    let k = y_one_hot.ncols();
2365    let m = k - 1;
2366    let n_obs = y_one_hot.nrows();
2367    let penalty_scale = multinomial_formula_penalty_scale(k);
2368    let per_term_penalties: Vec<PenaltyMatrix> = design
2369        .penalties_as_penalty_matrix()
2370        .into_iter()
2371        .map(|penalty| scale_multinomial_formula_penalty(penalty, penalty_scale))
2372        .collect();
2373    let per_term_nullspace_dims = design.nullspace_dims.clone();
2374
2375    // ── Custom-family driven REML/LAML path ───────────────────────────────
2376    // Each active class becomes one ParameterBlockSpec, all sharing X and the
2377    // per-term penalty list. `initial_log_lambdas` is seeded from the caller's
2378    // `init_lambda` (one entry per term).
2379    let design_arc = Arc::new(x_dense);
2380    let penalties_arc = Arc::new(per_term_penalties);
2381    let nullspace_dims_arc = Arc::new(per_term_nullspace_dims);
2382    let weights = resolve_multinomial_row_weights(data, config)?;
2383    if weights.len() != n_obs {
2384        crate::bail_invalid_estim!(
2385            "multinomial fit: weight column length {} != N = {n_obs}",
2386            weights.len()
2387        );
2388    }
2389    // First attempt runs the UNBIASED penalized-REML criterion (no Firth
2390    // shrinkage toward the uniform simplex); the Jeffreys/Firth proper prior is
2391    // armed conditionally below, only on separation evidence (#715/#753 — see
2392    // `multinomial_formula_separation_evidence`).
2393    let log_init = init_lambda.ln();
2394    let family = MultinomialFamily::new(
2395        y_one_hot.clone(),
2396        weights,
2397        k,
2398        design_arc.clone(),
2399        penalties_arc.clone(),
2400        nullspace_dims_arc.clone(),
2401    )
2402    .map_err(EstimationError::InvalidInput)?
2403    .with_joint_jeffreys_term(false)
2404    // gam#1587: the per-block smooth penalties are emptied (the centered `M⊗S_t`
2405    // joint penalty is the sole smoothing carrier), so the `init_lambda` warm
2406    // start must seed the JOINT penalty's `initial_log_lambda` — the per-block
2407    // `initial_log_lambdas` loop below is now a no-op (empty per-block list).
2408    .with_initial_log_lambda(log_init);
2409    let mut blocks = family.build_block_specs();
2410    for spec_block in blocks.iter_mut() {
2411        for v in spec_block.initial_log_lambdas.iter_mut() {
2412            *v = log_init;
2413        }
2414    }
2415
2416    // ── Outer-derivative policy: dimension-gated exact curvature ────────────
2417    // The total smoothing-parameter dimension is `D = (K−1) · n_terms`.
2418    // Medium-D formula fits need exact curvature to keep lambda selection away
2419    // from over-smoothed caps, while smooth-by-factor `D = 8` models still avoid
2420    // the O(D²) dense Hessian path.
2421    let total_rho_dim = m.saturating_mul(penalties_arc.len());
2422    let use_outer_hessian = multinomial_formula_use_outer_hessian(total_rho_dim);
2423
2424    // ── Inner-vs-outer control split (#715 non-convergence root cause) ────────
2425    // The legacy `max_iter` / `tol` parameters are the *outer* REML/LAML
2426    // smoothing-parameter optimization controls — "how hard to search λ". The
2427    // earlier wiring routed them straight into `inner_max_cycles` / `inner_tol`,
2428    // capping the joint-Newton inner solve at `max_iter` (=50 in the quality
2429    // suite) cycles with a `tol`-tight (=1e-8) KKT target. That is the #715
2430    // hang: near the simplex boundary the softmax Fisher weight
2431    // `W = diag(p) − p pᵀ` collapses, so `H = JᵀWJ + S_λ` is full-rank but
2432    // ILL-CONDITIONED. The self-vanishing Levenberg–Marquardt damping
2433    // (`levenberg_on_ill_conditioning()`) that keeps the inner solve from
2434    // oscillating on those near-singular modes makes it converge only
2435    // GEOMETRICALLY (linearly), not quadratically. Reaching a 1e-8 relative KKT
2436    // residual under geometric descent needs FAR more than 50 cycles, so the
2437    // inner returned `converged = false` on every outer ρ-evaluation; with the
2438    // exact-Hessian outer optimizer on `FallbackPolicy::Disabled` that rejects
2439    // every ρ-step — each rejected eval still paying a near-full 50-cycle inner
2440    // solve plus the O(D²) pairwise outer-Hessian directional work — so the
2441    // outer never certifies and the fit runs unbounded (the observed >8-minute
2442    // non-termination). The certificate cannot be reached, not merely slow.
2443    //
2444    // Fix: give the INNER joint-Newton the framework's principled production
2445    // budget (`DEFAULT_CUSTOM_FAMILY_INNER_MAX_CYCLES` cycles at the default
2446    // `inner_tol`), which exists precisely so an ill-conditioned LM-damped solve
2447    // can certify a stationary KKT point instead of being declared non-converged
2448    // prematurely — and the KKT/objective certificates still exit in a handful
2449    // of cycles on the well-conditioned interior fits, so this is free there.
2450    // The caller's `max_iter` / `tol` become the OUTER controls they were always
2451    // meant to be (smoothing-parameter search depth / accuracy). The inner KKT
2452    // target is kept no tighter than the outer accuracy can consume — and no
2453    // tighter than the softmax objective's f64 noise floor on near-separable
2454    // fits (see `MULTINOMIAL_FORMULA_INNER_TOL`).
2455    let outer_max_iter = max_iter.max(1);
2456    // The OUTER REML/LAML smoothing-parameter search must converge to a
2457    // well-calibrated ρ-gradient tolerance, NOT to the caller's (typically very
2458    // tight) INNER KKT tolerance. The #715 control-split repurposed the caller's
2459    // `tol` as the outer control, but feeding an inner-scale `tol = 1e-8`
2460    // straight into `outer_tol` makes REML grind dozens of extra exact-gradient
2461    // outer iterations (each an O(D·p³) Laplace-derivative assembly over the full
2462    // P·M joint design) to squeeze ρ digits that no longer move the fitted
2463    // surface — the smooth-by-factor 269s wall-clock overrun (#1082).
2464    //
2465    // The right target is the framework's CALIBRATED REML convergence tolerance,
2466    // `MULTINOMIAL_OUTER_REML_TOL = 1e-7` — the same value the primary GLM REML
2467    // outer uses (`solver::fit_orchestration::materialize` `tol: 1e-7`, mirrored by the
2468    // `LOG_LAMBDA_TOL`/`KKT_TOL_*` constants across the REML stack). At 1e-7 the
2469    // λ-search reaches the genuine REML optimum (so the recovered probability
2470    // surface matches the mature reference), but it does NOT chase the last
2471    // surface-irrelevant ρ digits down to 1e-8. The earlier 1e-5 floor (the
2472    // generic `BlockwiseFitOptions` default) was too LOOSE: the optimizer halted
2473    // in a low-curvature region with λ still well above its optimum, UNDER-fitting
2474    // the smooth-by-factor surface (truth-RMSE 0.164 vs VGAM's 0.061). So the
2475    // outer tolerance is floored at the calibrated REML tol — never tighter than
2476    // it (perf), never looser (accuracy) — while the caller's `tol` continues to
2477    // drive the INNER joint-Newton KKT target (`inner_tol` below), where its
2478    // precision actually matters.
2479    let outer_tol = if tol.is_finite() && tol > 0.0 {
2480        tol.max(MULTINOMIAL_OUTER_REML_TOL)
2481    } else {
2482        MULTINOMIAL_OUTER_REML_TOL
2483    };
2484    // #1082 root cause: the outer convergence test derives BOTH the absolute
2485    // projected-gradient floor (`max(outer_tol, n·1e-9)`) AND the relative-cost
2486    // stop (`rel_cost = outer_tol`) from the single `outer_tol`. The accuracy of
2487    // the smooth-by-factor surface is governed by the ABSOLUTE floor reaching the
2488    // n-scaled REML resolution `n·1e-9` (≈ 1.8e-6 at n = 1800) — that is why the
2489    // earlier 1e-5 floor UNDER-fit (its absolute floor was pinned at 1e-5, well
2490    // above the genuine optimum's gradient) and why 1e-7 recovered accuracy (it
2491    // unpins the floor down to the n-scaled 1.8e-6). But tightening `outer_tol`
2492    // to 1e-7 ALSO tightened the rel-cost stop to 1e-7, which on this family's
2493    // dead-flat REML ridge NEVER trips — so the optimizer no longer converges and
2494    // grinds all the way to `outer_max_iter`, each surplus step an O(D·p³) Laplace-
2495    // derivative assembly over the 382-dim joint design (the >600s wall-clock
2496    // overrun; tightening tol REINTRODUCED the crawl the 1e-5 floor had removed).
2497    //
2498    // The two requirements live on two different criteria, so they must be set
2499    // independently. Keep `outer_tol = 1e-7` (drives the accurate absolute floor)
2500    // but FLOOR the relative-cost stop at the framework default 1e-5 (the loose,
2501    // fast value that resolves the cost-decrease plateau without chasing the flat
2502    // tail). The absolute n·1e-9 floor still gates final λ accuracy; the rel-cost
2503    // stop just lets the optimizer DECLARE convergence on the flat ridge instead
2504    // of crawling to the iteration cap.
2505    let outer_rel_cost_tol = Some(BlockwiseFitOptions::default().outer_tol);
2506    let inner_tol = MULTINOMIAL_FORMULA_INNER_TOL.max(tol.max(0.0));
2507
2508    let options = BlockwiseFitOptions {
2509        inner_max_cycles: crate::custom_family::DEFAULT_CUSTOM_FAMILY_INNER_MAX_CYCLES,
2510        inner_tol,
2511        outer_max_iter,
2512        outer_tol,
2513        outer_rel_cost_tol,
2514        rho_lower_bound: multinomial_formula_min_lambda(y_one_hot.view()).ln(),
2515        ridge_floor: MULTINOMIAL_FORMULA_RIDGE_FLOOR,
2516        // #747: the stabilization floor is SOLVER-ONLY — it keeps the inner
2517        // joint-Newton linear solve finite during screening (bounding the step
2518        // `(H+δI)⁻¹∇` away from a near-separable, rank-deficient curvature) but
2519        // is excluded from the REML objective, the penalty log-determinant, and
2520        // the Laplace Hessian. The earlier default (`explicit_stabilization_pospart`)
2521        // folded `½·δ·‖β‖²` and a `δ`-shift of the log-determinant into the
2522        // criterion, shrinking every identified coefficient off the MLE and
2523        // perturbing smoothing-parameter selection — a fixed-λ prior masking
2524        // separation, not a numerical stabilizer. With the floor solver-only the
2525        // optimized objective is the true penalized REML criterion (value tracks
2526        // its analytic gradient), and the smooth directions remain governed
2527        // solely by their own REML-selected `λ`.
2528        ridge_policy: gam_problem::RidgePolicy::solver_only(),
2529        use_outer_hessian,
2530        // #715 real-data arm ("canonical-gauge null direction rejects all REML
2531        // seeds"): skip the multi-seed outer screening cascade and let the
2532        // pinned `init_lambda` ρ flow straight to the outer optimizer.
2533        //
2534        // The multinomial family declares `levenberg_on_ill_conditioning() ->
2535        // true`: near the simplex boundary (the near-separable penguins regime)
2536        // the softmax Fisher weight `W = diag(p) − p pᵀ → 0`, so the joint
2537        // information `H = JᵀWJ + S_λ` can become full-rank but
2538        // ILL-CONDITIONED. The self-vanishing LM damping that keeps the inner
2539        // joint-Newton from oscillating on those near-singular modes converges
2540        // only GEOMETRICALLY. The default screening policy ranks candidate seeds
2541        // with a 2-cycle inner cap (`outer_seed_config`); under geometric
2542        // LM-damped descent two cycles never reach a finite, meaningful proxy
2543        // objective, so EVERY capped seed can collapse to non-finite cost and
2544        // the cascade escalates to ×4, ×16, then an UNCAPPED full inner solve
2545        // PER SEED on the near-singular Hessian. That is the adapter-level face
2546        // of "all REML startup seeds rejected" and the multi-minute timeout.
2547        //
2548        // The pinned seed is already principled here: `init_lambda` gives every
2549        // (class, term) ρ a sensible moderate warm start, and the per-term
2550        // effective-df-floor upper bounds (`effective_df_floor_rho_upper_bounds`,
2551        // #715 arm (a)) keep any λ from collapsing the smooth onto its polynomial
2552        // null space. So the outer ARC/BFGS optimizer performs the real REML ρ
2553        // search from this seed; screening only adds the cascade cost and, on the
2554        // near-separable arm, the rejection stall.
2555        screen_initial_rho: false,
2556        // #1101: compute the joint Laplace posterior covariance `H⁻¹` (and the
2557        // influence matrix `F = H⁻¹ X'WX`) at the converged mode so the saved
2558        // model can surface delta-method per-class probability standard errors
2559        // and Wald smooth-term p-values. The driver factorizes the penalized
2560        // Hessian during the inner solve regardless; this only asks it to keep
2561        // and invert the factor instead of discarding it.
2562        compute_covariance: true,
2563        ..BlockwiseFitOptions::default()
2564    };
2565    // ── Conditional Firth/Jeffreys engagement (#715 arm (b) / #753) ──────────
2566    // Attempt 1: the unbiased criterion (Jeffreys disarmed above). If the
2567    // returned mode is converged, finite, and interior, it is the exact penalized-REML
2568    // optimum with zero Firth bias — accept it (this is the synthetic-arm /
2569    // interior-data path, #715 arm (a)). If the solve FAILS (e.g. the
2570    // (quasi-)separated penguins geometry where `(H + S_λ)v ≈ 0` along
2571    // penalty-null directions for EVERY ρ rejects every REML startup seed) or
2572    // returns a non-finite artifact, that is direct separation evidence:
2573    // re-solve once with the full-span Jeffreys/Firth proper prior armed, which
2574    // supplies the O(1) curvature on the quotient-null subspace that smoothing
2575    // parameters mathematically cannot (`Sv = 0` ⇒ λ never touches `v`). The
2576    // Firth refit is the accepted result only when the unbiased formula solve
2577    // failed, did not converge on its full budget, or blew up; finite
2578    // formula-path logits can be large on valid near-separated optima and
2579    // should not be shrunk toward the uniform simplex once the unbiased outer
2580    // solve has actually certified.
2581    let mut unbiased_probe_options = options.clone();
2582    unbiased_probe_options.outer_max_iter = unbiased_probe_options
2583        .outer_max_iter
2584        .min(MULTINOMIAL_UNBIASED_PROBE_OUTER_MAX_ITER);
2585    // The FINAL accepted Firth/Jeffreys refit runs to the caller's full outer
2586    // budget: it is the result we ship, so it must reach the genuine REML
2587    // optimum, not a truncated iterate. The near-separable penguin refit that
2588    // motivated #1082's wall-clock concern is now halted honestly at its true
2589    // bound optimum by the KKT-stationary-at-bound guard
2590    // (`CostStallGuard`, #1082 / 64711ed82) and the Newton-decrement residual
2591    // certificate (363af9b56 / 2c9580b1f): on separable data the outer ARC
2592    // certifies and stops early on its own, so no artificial iteration cap is
2593    // needed to land in budget. On non-separable data (e.g. the
2594    // `vgam_smooth_by_factor` double-penalty arm) the refit needs the caller's
2595    // full budget to converge, which a `.min(20)` cap would cut off — accepting
2596    // a non-converged fit, which is dishonest. So the refit keeps `options`
2597    // unchanged. Only the discarded unbiased separation probe above is capped.
2598    let firth_refit_options = &options;
2599
2600    let run_firth_refit = |evidence: String| {
2601        let firth_family = family.clone().with_joint_jeffreys_term(true);
2602        fit_custom_family_with_rho_prior(
2603            &firth_family,
2604            &blocks,
2605            firth_refit_options,
2606            gam_problem::RhoPrior::Flat,
2607        )
2608        .map_err(|err| {
2609            EstimationError::InvalidInput(format!(
2610                "multinomial REML: Firth/Jeffreys-armed refit (separation evidence: \
2611                 {evidence}) failed: {err}"
2612            ))
2613        })
2614    };
2615
2616    // #1082: the capped unbiased probe and the (separable-path) Firth decision
2617    // are driven by separation scans over the full P×M logit block. The previous
2618    // match recomputed `multinomial_formula_separation_evidence` /
2619    // `..._unresolved_probe_separation_evidence` in BOTH the match guard AND the
2620    // arm body — three to four full logit walks per fit, paid on the hot
2621    // near-separable penguin path where this branch fires every iterate. Run the
2622    // probe once, evaluate each scan once into a binding, and branch on the
2623    // precomputed results. Behaviour is identical (same scans, same order of
2624    // precedence: converged-interior, unresolved-probe-separation,
2625    // no-separation-needs-full-solve, otherwise-Firth); only the duplicate
2626    // O(n·classes) scans are removed.
2627    let probe_attempt = fit_custom_family_with_rho_prior(
2628        &family,
2629        &blocks,
2630        &unbiased_probe_options,
2631        gam_problem::RhoPrior::Flat,
2632    );
2633    let fit = match probe_attempt {
2634        Ok(probe_fit) => {
2635            let separation = multinomial_formula_separation_evidence(&probe_fit.block_states);
2636            if separation.is_none() {
2637                // Fit existence proves both optimization layers certified; no
2638                // post-hoc convergence flag is needed.
2639                probe_fit
2640            } else {
2641                // A certified unbiased optimum can still exhibit separation;
2642                // use the already-computed evidence to select the Firth target.
2643                let evidence = separation.expect("checked as present");
2644                run_firth_refit(evidence)?
2645            }
2646        }
2647        Err(err) => run_firth_refit(format!("unbiased-criterion REML solve failed: {err}"))?,
2648    };
2649    if let Some(err) = multinomial_formula_separation_diagnostic(
2650        fit.inner_cycles,
2651        fit.outer_iterations,
2652        &fit.block_states,
2653    ) {
2654        return Err(err);
2655    }
2656
2657    // ── Repack coefficients (P, K-1) from per-block β vectors ─────────────
2658    if fit.blocks.len() != m {
2659        crate::bail_invalid_estim!(
2660            "multinomial REML: expected {m} fitted blocks (K-1), got {}",
2661            fit.blocks.len()
2662        );
2663    }
2664    let p_per_class = fit.blocks[0].beta.len();
2665    let mut coefficients_active = Array2::<f64>::zeros((p_per_class, m));
2666    for (a, block) in fit.blocks.iter().enumerate() {
2667        if block.beta.len() != p_per_class {
2668            crate::bail_invalid_estim!(
2669                "multinomial REML: block {a} has {} coefs, expected {p_per_class}",
2670                block.beta.len()
2671            );
2672        }
2673        for i in 0..p_per_class {
2674            coefficients_active[[i, a]] = block.beta[i];
2675        }
2676    }
2677    // Map the standardized-column coefficients back to raw units (the exact
2678    // inverse of the conditioning reparameterization above): β_raw = b/s, with
2679    // the centering mass `Σ_j b_j·m_j/s_j` returned to the intercept.
2680    if !parametric_standardization.is_empty() {
2681        let intercept_col = design.intercept_range.clone().next();
2682        for a in 0..m {
2683            let mut intercept_adjust = 0.0;
2684            for &(col, center, scale) in &parametric_standardization {
2685                if col < p_per_class {
2686                    let raw = coefficients_active[[col, a]] / scale;
2687                    coefficients_active[[col, a]] = raw;
2688                    intercept_adjust += raw * center;
2689                }
2690            }
2691            if let Some(i0) = intercept_col
2692                && i0 < p_per_class
2693            {
2694                coefficients_active[[i0, a]] -= intercept_adjust;
2695            }
2696        }
2697    }
2698    // Flatten every (class, term) smoothing parameter in block-major order
2699    // (class 0's terms, then class 1's, …). With per-term penalties each block
2700    // now carries one λ per smooth term, so a single λ per class would discard
2701    // the independent per-term selection that fixes #561. `lambdas_per_block`
2702    // segments the flat vector by class so callers can recover per-term λ.
2703    // ── gam#1587/#561 joint-penalty reconstruction ───────────────────────────
2704    // Under the #1587 centered-metric architecture every active class block
2705    // leaves its per-block penalty list EMPTY — the entire fit's smoothing rides
2706    // on a single full-width JOINT penalty `S_λ = Σ_t λ_t (M ⊗ S_t)` whose one
2707    // shared `λ_t` per smooth component is selected by the outer REML loop and
2708    // surfaced on `fit.artifacts.joint_log_lambdas`. So `fit.blocks[a].lambdas`
2709    // is `[]`, the inference layer's per-block trace channel is empty, and the
2710    // older per-block reporting (`lambdas_per_block = [0, 0]`, `edf_per_class =
2711    // None`, …) collapsed (#561 reopen).
2712    //
2713    // Reconstruct the per-(class, component) λ and the influence-matrix EDF
2714    // directly from the selected joint `λ_t` and the COUPLED penalty
2715    // `S_λ = Σ_t λ_t (M ⊗ S_t)` (NOT a block-diagonal `Σ_t λ_{a,t} S_t`: the
2716    // centered metric `M` couples classes off the block diagonal, so a
2717    // block-diagonal `S_λ` would mis-state both the influence matrix and every
2718    // trace). With `H⁻¹ = fit.covariance_conditional` now assembled WITH the
2719    // joint penalty (the `compute_joint_covariance` fix), the influence matrix is
2720    // exactly `F = I − H⁻¹ S_λ`, its per-class diagonal-block trace is the honest
2721    // per-class EDF, and `Σ_a edf_a = tr(F) = edf_total`.
2722    let joint_recon = fit.artifacts.joint_log_lambdas.as_ref().and_then(|jll| {
2723        let n_components = penalties_arc.len();
2724        if jll.len() != n_components || n_components == 0 {
2725            return None;
2726        }
2727        let expected_joint = p_per_class.saturating_mul(m);
2728        let hinv = fit
2729            .covariance_conditional
2730            .as_ref()
2731            .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)?;
2732        // The coupled joint penalty components `M ⊗ S_t` at the selected `λ_t`,
2733        // in raw stacked (class-major) coordinates — exactly the operator the
2734        // inner solve and the now-fixed covariance path penalize with.
2735        let joint_specs = family.centered_joint_penalty_specs();
2736        if joint_specs.len() != n_components {
2737            return None;
2738        }
2739        let lam: Vec<f64> = jll.iter().map(|&l| l.exp()).collect();
2740        // Per-component `H⁻¹ (M ⊗ S_t)` (full mp×mp), reused for both the joint
2741        // influence matrix and the per-(class, component) trace decomposition.
2742        let mut hinv_st: Vec<Array2<f64>> = Vec::with_capacity(n_components);
2743        for spec in &joint_specs {
2744            if spec.matrix.nrows() != expected_joint || spec.matrix.ncols() != expected_joint {
2745                return None;
2746            }
2747            hinv_st.push(hinv.dot(&spec.matrix));
2748        }
2749        // F = I − H⁻¹ S_λ = I − Σ_t λ_t H⁻¹ (M ⊗ S_t).
2750        let mut f = Array2::<f64>::eye(expected_joint);
2751        for (t, hs) in hinv_st.iter().enumerate() {
2752            f.scaled_add(-lam[t], hs);
2753        }
2754        // Per-class diagonal-block trace of F (the honest per-class EDF), and the
2755        // per-(class, component) penalty trace `tr_{a,t} = λ_t · Σ_{i∈class a}
2756        // (H⁻¹ (M⊗S_t))[i,i]` for the per-penalty EDF rollup.
2757        let mut edf_per_class = Vec::with_capacity(m);
2758        // class-major per-penalty EDF (class 0's components, then class 1's, …),
2759        // aligned 1:1 with the flat per-component λ replicated per class.
2760        let mut edf_per_penalty = Vec::with_capacity(m * n_components);
2761        for a in 0..m {
2762            let base = a * p_per_class;
2763            let mut class_trace = 0.0_f64;
2764            for t in 0..n_components {
2765                let mut tr_at = 0.0_f64;
2766                for i in 0..p_per_class {
2767                    tr_at += hinv_st[t][[base + i, base + i]];
2768                }
2769                tr_at *= lam[t];
2770                class_trace += tr_at;
2771                // A single component's per-class trace EDF `rank(S_t) − tr_{a,t}`,
2772                // bounded by its local rank (≤ p_per_class).
2773                let ns_t = nullspace_dims_arc.get(t).copied().unwrap_or(0);
2774                let rank_t = (p_per_class as f64 - ns_t as f64).max(0.0);
2775                edf_per_penalty.push((rank_t - tr_at).clamp(0.0, p_per_class as f64));
2776            }
2777            edf_per_class.push((p_per_class as f64 - class_trace).clamp(0.0, p_per_class as f64));
2778        }
2779        Some((f, edf_per_class, edf_per_penalty, n_components, lam))
2780    });
2781
2782    // Flatten every (class, component) smoothing parameter in class-major order.
2783    // Under the joint-penalty architecture each active class carries the SAME
2784    // per-component λ set (the centered metric ties `λ_t` across classes for
2785    // reference-class invariance), so the flat vector is the selected `λ_t`
2786    // replicated `K-1` times and `lambdas_per_block = [n_components; K-1]`. When
2787    // the joint reconstruction is unavailable (legacy fixed-λ path or absent
2788    // covariance) fall back to the raw — now empty — per-block λ lists.
2789    let (lambdas_per_block, lambdas_flat): (Vec<usize>, Vec<f64>) = match joint_recon.as_ref() {
2790        Some((_, _, _, n_components, lam)) => {
2791            let per_block = vec![*n_components; m];
2792            let mut flat = Vec::with_capacity(m * n_components);
2793            for _ in 0..m {
2794                flat.extend(lam.iter().copied());
2795            }
2796            (per_block, flat)
2797        }
2798        None => {
2799            let per_block: Vec<usize> = fit.blocks.iter().map(|b| b.lambdas.len()).collect();
2800            let flat: Vec<f64> = fit
2801                .blocks
2802                .iter()
2803                .flat_map(|b| b.lambdas.iter().copied())
2804                .collect();
2805            (per_block, flat)
2806        }
2807    };
2808    // Per-active-class effective degrees of freedom, length `K-1`, summing to
2809    // the model `edf_total`. The REML inference block reports `edf_by_block` as
2810    // ONE entry per *penalty block* (per (class, term, penalty)), each computed
2811    // as `rank(S_kk) − tr(H⁻¹ λ_kk S_kk)`. That per-block sum OVER-COUNTS the
2812    // model EDF whenever several penalties share one coefficient range — a
2813    // double-penalty / te / ti / adaptive smooth has ≥2 penalty blocks over the
2814    // same columns, so `Σ_kk rank(S_kk) > p` and `Σ_kk edf_by_block > edf_total`
2815    // (the observed ~79 for a ~24-coefficient model). Handing that raw per-block
2816    // vector out as the documented length-(K-1) per-class EDF is therefore both
2817    // the wrong LENGTH (it is `Σ_a n_blocks_a`, not `K-1`) and an over-count.
2818    //
2819    // The honest per-class EDF is the influence-matrix trace over each class's
2820    // coefficient block. Classes occupy DISJOINT `p_per_class`-wide coefficient
2821    // ranges, and the per-block traces `tr_kk = tr(H⁻¹ λ_kk S_kk)` are additive
2822    // (no rank double-counting), so class `a`'s EDF is
2823    // `p_per_class − Σ_{kk ∈ class a} tr_kk`, and `Σ_a edf_a = m·p_per_class −
2824    // Σ_kk tr_kk = p − Σ tr_kk = edf_total` exactly. Segment the block-major
2825    // `penalty_block_trace` by `lambdas_per_block` (the same per-class λ-count
2826    // segmentation `lambdas_flat` uses). Fall back to `None` when the trace
2827    // channel is unavailable or mis-shaped (legacy fixed-λ path), exactly as the
2828    // raw `edf_by_block` map did before.
2829    let edf_per_class = joint_recon
2830        .as_ref()
2831        .map(|(_, epc, _, _, _)| epc.clone())
2832        .or_else(|| {
2833            // Legacy per-block trace path (fixed-λ / pre-#1587 fits whose
2834            // smoothing is still carried per block). Segment the block-major
2835            // `penalty_block_trace` by `lambdas_per_block`, exactly as before.
2836            fit.inference.as_ref().and_then(|info| {
2837                let traces = &info.penalty_block_trace;
2838                if traces.len() != lambdas_per_block.iter().sum::<usize>() {
2839                    return None;
2840                }
2841                let mut per_class = Vec::with_capacity(m);
2842                let mut cursor = 0usize;
2843                for &n_blocks in &lambdas_per_block {
2844                    let class_trace: f64 = traces[cursor..cursor + n_blocks].iter().sum();
2845                    per_class
2846                        .push((p_per_class as f64 - class_trace).clamp(0.0, p_per_class as f64));
2847                    cursor += n_blocks;
2848                }
2849                Some(per_class)
2850            })
2851        });
2852    // Per-PENALTY EDF: the inference layer's `edf_by_block` is already the
2853    // clamped per-penalty-block trace EDF `rank(S_k) − λ_k·tr(H⁻¹ S_k)`, one
2854    // entry per smoothing parameter and block-major aligned 1:1 with the flat
2855    // `lambdas`. Surface it verbatim (guarding only on the length contract) so
2856    // consumers can inspect per-(class, term, penalty) collapse onto the null
2857    // space — a signal the per-class EDF SUM hides. This is NOT a per-class
2858    // total: with double-penalty smooths `Σ_k rank(S_k) > p_per_class`, so the
2859    // entries deliberately need not sum to the model EDF (the per-class field
2860    // carries that contract instead).
2861    let edf_per_penalty = joint_recon
2862        .as_ref()
2863        .map(|(_, _, epp, _, _)| epp.clone())
2864        .or_else(|| {
2865            // Legacy per-block path: the inference layer's `edf_by_block` is
2866            // already the clamped per-penalty-block trace EDF, aligned 1:1 with
2867            // the flat `lambdas`.
2868            fit.inference.as_ref().and_then(|info| {
2869                if info.edf_by_block.len() != lambdas_flat.len() {
2870                    return None;
2871                }
2872                Some(
2873                    info.edf_by_block
2874                        .iter()
2875                        .map(|&e| e.max(0.0))
2876                        .collect::<Vec<f64>>(),
2877                )
2878            })
2879        });
2880    let coefficients_flat: Vec<f64> = coefficients_active.iter().copied().collect();
2881
2882    // #1101: surface the joint Laplace posterior covariance `H⁻¹` (block-ordered
2883    // [β_0; …; β_{K-2}]) and the influence matrix `F = H⁻¹ X'WX` the REML driver
2884    // computed at the converged mode. These power the predict path's delta-method
2885    // per-class probability standard errors and the summary's Wald smooth-term
2886    // tests. The joint matrices are `(P·M)×(P·M)`. The covariance is mapped back
2887    // to RAW units (see below) so it pairs with the raw predict design; the
2888    // influence is kept in the fitted basis (the Wald table only slices penalized
2889    // columns, which the standardization affine leaves identity-mapped).
2890    let expected_joint = p_per_class.checked_mul(m).ok_or_else(|| {
2891        EstimationError::InvalidInput(
2892            "multinomial posterior covariance dimension overflowed usize".to_string(),
2893        )
2894    })?;
2895    // The joint Hessian (and thus `H⁻¹`) was assembled in the STANDARDIZED
2896    // parametric basis used during fitting, while the saved coefficients and the
2897    // raw predict design are in raw units. Map the covariance to raw units with
2898    // the same exact affine reparameterization `β_raw = A β_std`: for each
2899    // standardized parametric column `col`, `β_raw[col] = β_std[col]/scale` and
2900    // the intercept absorbs `−Σ_col (center/scale)·β_std[col]`. So `A = I` except
2901    // `A[col,col] = 1/scale` and `A[i0,col] = −center/scale`, replicated
2902    // block-diagonally per active class, and `Cov_raw = A Cov_std Aᵀ`. With no
2903    // standardization (`parametric_standardization` empty) `A = I` and this is a
2904    // no-op. The smooth-term (penalized) columns are untouched by `A`, so the
2905    // Wald table's per-term blocks are identical in both bases.
2906    let intercept_col0 = design.intercept_range.clone().next();
2907    let build_per_class_affine = |amat: &mut Array2<f64>| {
2908        for &(col, center, scale) in &parametric_standardization {
2909            if col >= p_per_class {
2910                continue;
2911            }
2912            amat[[col, col]] = 1.0 / scale;
2913            if let Some(i0) = intercept_col0
2914                && i0 < p_per_class
2915            {
2916                amat[[i0, col]] = -center / scale;
2917            }
2918        }
2919    };
2920    let coefficient_covariance_flat = fit
2921        .covariance_conditional
2922        .as_ref()
2923        .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)
2924        .map(|cov_std| {
2925            if parametric_standardization.is_empty() {
2926                return cov_std.iter().copied().collect::<Vec<f64>>();
2927            }
2928            // Block-diagonal joint A (same per active class).
2929            let mut a_joint = Array2::<f64>::eye(expected_joint);
2930            let mut a_class = Array2::<f64>::eye(p_per_class);
2931            build_per_class_affine(&mut a_class);
2932            for a in 0..m {
2933                let base = a * p_per_class;
2934                for i in 0..p_per_class {
2935                    for j in 0..p_per_class {
2936                        a_joint[[base + i, base + j]] = a_class[[i, j]];
2937                    }
2938                }
2939            }
2940            let cov_raw = a_joint.dot(cov_std).dot(&a_joint.t());
2941            cov_raw.iter().copied().collect::<Vec<f64>>()
2942        })
2943        .ok_or_else(|| {
2944            EstimationError::InvalidInput(format!(
2945                "multinomial REML converged without the required {expected_joint}x{expected_joint} joint posterior covariance"
2946            ))
2947        })?;
2948    // The influence matrix `F = H⁻¹ X'WX = H⁻¹(H − S_λ) = I − H⁻¹ S_λ`. The
2949    // exact-Newton multinomial blocks carry no IRLS pseudo-data, so the generic
2950    // inference path does not export `coefficient_influence`; reconstruct it
2951    // exactly here. Under the #1587 joint-penalty architecture the penalty is the
2952    // COUPLED centered metric `S_λ = Σ_t λ_t (M ⊗ S_t)` (off the class-block
2953    // diagonal), already assembled in `joint_recon` above, so reuse that exact
2954    // `F`. Only fall back to the legacy block-diagonal `Σ_t λ_{a,t} S_t`
2955    // reconstruction when the joint reconstruction is unavailable (pre-#1587
2956    // per-block fits whose class blocks still carry their own penalties).
2957    let coefficient_influence_flat = match joint_recon.as_ref() {
2958        Some((f, _, _, _, _)) => Some(f.iter().copied().collect::<Vec<f64>>()),
2959        None => fit
2960            .covariance_conditional
2961            .as_ref()
2962            .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)
2963            .and_then(|hinv| {
2964                if fit.blocks.len() != m {
2965                    return None;
2966                }
2967                // Joint S_λ (block-diagonal across active classes).
2968                let mut s_lambda = Array2::<f64>::zeros((expected_joint, expected_joint));
2969                for (a, block) in fit.blocks.iter().enumerate() {
2970                    if block.lambdas.len() != penalties_arc.len() {
2971                        return None;
2972                    }
2973                    let base = a * p_per_class;
2974                    for (t, pen) in penalties_arc.iter().enumerate() {
2975                        let lam = block.lambdas[t];
2976                        if lam == 0.0 {
2977                            continue;
2978                        }
2979                        let dense = pen.to_dense();
2980                        if dense.nrows() != p_per_class || dense.ncols() != p_per_class {
2981                            return None;
2982                        }
2983                        for i in 0..p_per_class {
2984                            for j in 0..p_per_class {
2985                                s_lambda[[base + i, base + j]] += lam * dense[[i, j]];
2986                            }
2987                        }
2988                    }
2989                }
2990                // F = I − H⁻¹ S_λ.
2991                let hinv_s = hinv.dot(&s_lambda);
2992                let mut f = Array2::<f64>::eye(expected_joint);
2993                f -= &hinv_s;
2994                Some(f.iter().copied().collect::<Vec<f64>>())
2995            }),
2996    };
2997
2998    // Per-(smooth term) coefficient span within a single class block, deduped by
2999    // col_range (the #561 double-penalty migration emits two penalty blocks per
3000    // term sharing one col_range; the Wald test covers the whole term block once).
3001    let mut smooth_term_spans: Vec<MultinomialSmoothTermSpan> = Vec::new();
3002    for (pen_idx, bp) in design.penalties.iter().enumerate() {
3003        let col_start = bp.col_range.start;
3004        let col_end = bp.col_range.end;
3005        if col_start >= col_end || col_end > p_per_class {
3006            continue;
3007        }
3008        if smooth_term_spans
3009            .iter()
3010            .any(|s| s.col_start == col_start && s.col_end == col_end)
3011        {
3012            continue;
3013        }
3014        let label = design
3015            .penaltyinfo
3016            .get(pen_idx)
3017            .and_then(|info| info.termname.clone())
3018            .unwrap_or_else(|| format!("s{pen_idx}"));
3019        let nullspace_dim = design
3020            .nullspace_dims
3021            .get(pen_idx)
3022            .copied()
3023            .unwrap_or(0)
3024            .min(col_end - col_start);
3025        smooth_term_spans.push(MultinomialSmoothTermSpan {
3026            label,
3027            col_start,
3028            col_end,
3029            nullspace_dim,
3030        });
3031    }
3032
3033    // One descriptive label per penalty *component* within a single class block,
3034    // parallel to that block's λ slice (#1544). `design.penalties` is index-
3035    // parallel to every active class's `block.lambdas` (each block carries the
3036    // full per-component penalty list, validated above by
3037    // `block.lambdas.len() == penalties_arc.len()`), so iterating it in order
3038    // yields exactly `lambdas_per_block[0]` labels aligned with the per-block λ.
3039    // This is deliberately NOT deduped by col_range (unlike `smooth_term_spans`):
3040    // the double penalty's primary and null-space components share one col_range
3041    // but select independent λ, and each must keep its own label so the summary
3042    // renderer never collapses or drops a λ.
3043    let lambda_labels: Vec<String> = design
3044        .penalties
3045        .iter()
3046        .enumerate()
3047        .map(|(pen_idx, _)| penalty_component_label(design.penaltyinfo.get(pen_idx), pen_idx))
3048        .collect();
3049
3050    // Unpenalized deviance read directly from the converged unpenalized
3051    // log-likelihood the rho-prior driver already computed (issue #348):
3052    // MultinomialFamily::evaluate sets FamilyEvaluation.log_likelihood =
3053    // log_lik(η, y) with no penalty term, and that value flows unchanged into
3054    // UnifiedFitResult.log_likelihood. This reproduces the legacy fixed-λ
3055    // path's `deviance = -2 · log_lik` contract bit-for-bit, so the previous
3056    // row-by-row η = Xβ rebuild and softmax recompute were pure dead work.
3057    let deviance = -2.0 * fit.log_likelihood;
3058
3059    Ok(MultinomialSavedModel {
3060        formula: formula.to_string(),
3061        class_levels: class_levels.clone(),
3062        reference_class_index: class_levels.len() - 1,
3063        resolved_termspec: spec,
3064        coefficients_flat,
3065        p_per_class,
3066        n_active_classes: m,
3067        training_headers: data.headers.clone(),
3068        training_table_kind: config.training_table_kind.clone(),
3069        lambdas: lambdas_flat,
3070        lambdas_per_block,
3071        iterations: fit.inner_cycles,
3072        penalized_neg_log_likelihood: -fit.log_likelihood + 0.5 * fit.stable_penalty_term,
3073        deviance,
3074        edf_per_class,
3075        edf_per_penalty,
3076        coefficient_covariance_flat,
3077        coefficient_influence_flat,
3078        smooth_term_spans,
3079        lambda_labels,
3080    })
3081}
3082
3083/// Replay the saved termspec to build the predict-time dense design `X` on a
3084/// fresh dataset, realigning feature columns **by name** so the predict frame
3085/// need not reproduce the training column order or carry the response column.
3086/// Shared by every multinomial predict path (probabilities, SE bands, and the
3087/// posterior-predictive replicate draws).
3088fn build_multinomial_predict_design(
3089    model: &MultinomialSavedModel,
3090    data: &EncodedDataset,
3091) -> Result<Array2<f64>, EstimationError> {
3092    // The saved termspec stores feature columns as absolute indices into the
3093    // *training* table `[response, features...]`. Realign them onto this
3094    // dataset's columns by name, so prediction works on label-free new data
3095    // (the response column is never referenced by any term; issue #803).
3096    let predict_columns = data.column_map();
3097    let realigned = model.resolved_termspec.remap_feature_columns(
3098        |index| -> Result<usize, EstimationError> {
3099            let name = model.training_headers.get(index).ok_or_else(|| {
3100                EstimationError::InvalidInput(format!(
3101                    "multinomial predict: saved training column index {index} is out of bounds \
3102                     for {} training headers",
3103                    model.training_headers.len()
3104                ))
3105            })?;
3106            resolve_role_col(&predict_columns, name, "feature")
3107                .map_err(|err| EstimationError::InvalidInput(err.to_string()))
3108        },
3109    )?;
3110    let design = build_term_collection_design(data.values.view(), &realigned).map_err(|err| {
3111        EstimationError::InvalidInput(format!(
3112            "multinomial predict: rebuild design from saved termspec: {err}"
3113        ))
3114    })?;
3115    let x_dense = design
3116        .design
3117        .try_to_dense_by_chunks("multinomial predict design")
3118        .map_err(EstimationError::InvalidInput)?;
3119    if x_dense.ncols() != model.p_per_class {
3120        crate::bail_invalid_estim!(
3121            "multinomial predict: predict design has {} cols, saved model expects {}",
3122            x_dense.ncols(),
3123            model.p_per_class
3124        );
3125    }
3126    Ok(x_dense)
3127}
3128
3129/// Replay the saved termspec to build the predict-time design on a fresh
3130/// dataset, then evaluate softmax probabilities. The predict dataset must carry
3131/// the same feature columns the training data did, matched **by name** — it need
3132/// not reproduce the training column order, and in particular need not carry the
3133/// response column (prediction is for label-free new data).
3134pub fn predict_multinomial_formula(
3135    model: &MultinomialSavedModel,
3136    data: &EncodedDataset,
3137) -> Result<Array2<f64>, EstimationError> {
3138    model.validate()?;
3139    let x_dense = build_multinomial_predict_design(model, data)?;
3140    model.predict_probabilities(x_dense.view())
3141}
3142
3143/// Draw `n_draws` posterior-predictive replicate class-label assignments for a
3144/// saved multinomial model on fresh data (#1101). Rebuilds the predict design
3145/// exactly as [`predict_multinomial_formula`], then samples each row's class
3146/// from `Categorical(E[softmax(η) | data])` (see
3147/// [`MultinomialSavedModel::sample_replicate_classes`]). Returns an
3148/// `(n_draws, N)` matrix of class INDICES `0..K` aligned to `model.class_levels`,
3149/// deterministic in `seed`.
3150pub fn posterior_predict_multinomial_formula(
3151    model: &MultinomialSavedModel,
3152    data: &EncodedDataset,
3153    n_draws: usize,
3154    seed: u64,
3155) -> Result<Array2<u32>, EstimationError> {
3156    if n_draws == 0 {
3157        crate::bail_invalid_estim!("multinomial posterior_predict: n_draws must be >= 1");
3158    }
3159    model.validate()?;
3160    let x_dense = build_multinomial_predict_design(model, data)?;
3161    model.sample_replicate_classes(x_dense.view(), n_draws, seed)
3162}
3163
3164/// Predict posterior-mean class probabilities and integrated marginal
3165/// standard deviations for a saved multinomial model on fresh data.
3166pub fn predict_multinomial_formula_with_se(
3167    model: &MultinomialSavedModel,
3168    data: &EncodedDataset,
3169) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
3170    model.validate()?;
3171    let x_dense = build_multinomial_predict_design(model, data)?;
3172    model.predict_probabilities_with_se(x_dense.view())
3173}
3174
3175#[derive(Debug, Clone)]
3176pub struct MultinomialPredictionIntervals {
3177    pub mean: Array2<f64>,
3178    pub standard_error: Array2<f64>,
3179    pub mean_lower: Array2<f64>,
3180    pub mean_upper: Array2<f64>,
3181    pub level: f64,
3182}
3183
3184/// Build simplex-clamped normal moment intervals around the integrated
3185/// logistic-normal posterior mean. Both center and spread come from the same
3186/// deterministic posterior integral; no plug-in/delta quantity enters.
3187pub fn predict_multinomial_formula_with_intervals(
3188    model: &MultinomialSavedModel,
3189    data: &EncodedDataset,
3190    level: f64,
3191) -> Result<MultinomialPredictionIntervals, EstimationError> {
3192    if !(level.is_finite() && level > 0.0 && level < 1.0) {
3193        crate::bail_invalid_estim!(
3194            "multinomial prediction interval level must be finite and in (0, 1), got {level}"
3195        );
3196    }
3197    let (mean, standard_error) = predict_multinomial_formula_with_se(model, data)?;
3198    let z = gam_math::probability::standard_normal_quantile(0.5 + 0.5 * level)
3199        .map_err(EstimationError::InvalidInput)?;
3200    let mut mean_lower = mean.clone();
3201    let mut mean_upper = mean.clone();
3202    for ((row, class), &se) in standard_error.indexed_iter() {
3203        mean_lower[[row, class]] = (mean[[row, class]] - z * se).clamp(0.0, 1.0);
3204        mean_upper[[row, class]] = (mean[[row, class]] + z * se).clamp(0.0, 1.0);
3205    }
3206    Ok(MultinomialPredictionIntervals {
3207        mean,
3208        standard_error,
3209        mean_lower,
3210        mean_upper,
3211        level,
3212    })
3213}
3214
3215#[cfg(test)]
3216mod fisher_override_tests {
3217    use super::*;
3218
3219    /// Extra evidence used only for a NON-CONVERGED capped unbiased probe.
3220    ///
3221    /// A converged finite saturated formula fit is still a valid optimum and
3222    /// must be scored without Firth bias. A capped probe that failed to
3223    /// converge while it already carries separation-scale logits is different:
3224    /// spending the full unbiased outer budget on the same lambda-to-zero
3225    /// surface is the #1082 timeout. Route that case straight to the
3226    /// proper-prior refit.
3227    ///
3228    /// Kept in the test module: the production routing that would consume this
3229    /// (the non-converged-probe branch) is not currently wired, so the helper
3230    /// is test-support only rather than dead production code.
3231    fn multinomial_formula_unresolved_probe_separation_evidence(
3232        block_states: &[ParameterBlockState],
3233    ) -> Option<String> {
3234        if let Some(evidence) = multinomial_formula_separation_evidence(block_states) {
3235            return Some(evidence);
3236        }
3237
3238        let mut best = (0.0_f64, 0usize, 0usize);
3239        for (active_class, state) in block_states.iter().enumerate() {
3240            for (row, &value) in state.eta.iter().enumerate() {
3241                let abs = value.abs();
3242                if abs > best.0 {
3243                    best = (abs, row, active_class);
3244                }
3245            }
3246        }
3247        if best.0 >= MULTINOMIAL_SEPARATION_ETA_THRESHOLD {
3248            Some(format!(
3249                "separation-scale finite logit |eta[row {}, active class {}]| = {:.3e} \
3250                 after capped unbiased probe",
3251                best.1, best.2, best.0
3252            ))
3253        } else {
3254            None
3255        }
3256    }
3257    use ndarray::Array3;
3258
3259    fn toy() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
3260        let n = 15;
3261        let p = 2;
3262        let k = 3;
3263        let design =
3264            Array2::<f64>::from_shape_fn(
3265                (n, p),
3266                |(i, j)| {
3267                    if j == 0 { 1.0 } else { ((i + 2) as f64).cos() }
3268                },
3269            );
3270        let mut y = Array2::<f64>::zeros((n, k));
3271        for i in 0..n {
3272            y[[i, i % k]] = 1.0;
3273        }
3274        let penalty = Array2::<f64>::eye(p);
3275        let lambdas = Array1::<f64>::from_elem(k - 1, 0.5);
3276        (design, y, penalty, lambdas)
3277    }
3278
3279    #[test]
3280    fn fisher_override_none_reproduces_analytic() {
3281        // Issue #349: None override is exactly the analytic fit.
3282        let (design, y, penalty, lambdas) = toy();
3283        let mk = |over: Option<ndarray::ArrayView3<'_, f64>>| {
3284            fit_penalized_multinomial(MultinomialFitInputs {
3285                design: design.view(),
3286                y_one_hot: y.view(),
3287                penalty: penalty.view(),
3288                lambdas: lambdas.view(),
3289                row_weights: None,
3290                fisher_w_override: over,
3291                max_iter: 50,
3292                tol: 1.0e-9,
3293                resume_from: None,
3294            })
3295            .expect("fit must succeed")
3296        };
3297        let a = mk(None);
3298        let b = mk(None);
3299        for (x, z) in a
3300            .coefficients_active
3301            .iter()
3302            .zip(b.coefficients_active.iter())
3303        {
3304            assert_eq!(x, z);
3305        }
3306    }
3307
3308    #[test]
3309    fn exhausted_fixed_lambda_budget_is_typed_error_not_fit() {
3310        let (design, y, penalty, lambdas) = toy();
3311        let error = fit_penalized_multinomial(MultinomialFitInputs {
3312            design: design.view(),
3313            y_one_hot: y.view(),
3314            penalty: penalty.view(),
3315            lambdas: lambdas.view(),
3316            row_weights: None,
3317            fisher_w_override: None,
3318            max_iter: 0,
3319            tol: 1.0e-9,
3320            resume_from: None,
3321        })
3322        .expect_err("a zero-budget Newton solve must not mint a multinomial fit");
3323        assert!(matches!(
3324            error,
3325            EstimationError::FixedLambdaNewtonDidNotConverge {
3326                objective_value,
3327                checkpoint,
3328                ..
3329            } if objective_value.is_finite()
3330                && checkpoint.stage() == FixedLambdaSolverStage::MultinomialNewton
3331                && checkpoint.completed_iterations() == 0
3332        ));
3333    }
3334
3335    #[test]
3336    fn fixed_lambda_checkpoint_resume_matches_uninterrupted_solve() {
3337        let (design, y, penalty, lambdas) = toy();
3338        let interrupted = fit_penalized_multinomial(MultinomialFitInputs {
3339            design: design.view(),
3340            y_one_hot: y.view(),
3341            penalty: penalty.view(),
3342            lambdas: lambdas.view(),
3343            row_weights: None,
3344            fisher_w_override: None,
3345            max_iter: 1,
3346            tol: 1.0e-9,
3347            resume_from: None,
3348        })
3349        .expect_err("one Newton step must leave this coupled fit uncertified");
3350        let checkpoint = match interrupted {
3351            EstimationError::FixedLambdaNewtonDidNotConverge { checkpoint, .. } => checkpoint,
3352            other => panic!("unexpected interruption error: {other}"),
3353        };
3354        assert_eq!(
3355            checkpoint.stage(),
3356            FixedLambdaSolverStage::MultinomialNewton
3357        );
3358        assert_eq!(checkpoint.completed_iterations(), 1);
3359
3360        let resumed = fit_penalized_multinomial(MultinomialFitInputs {
3361            design: design.view(),
3362            y_one_hot: y.view(),
3363            penalty: penalty.view(),
3364            lambdas: lambdas.view(),
3365            row_weights: None,
3366            fisher_w_override: None,
3367            max_iter: 49,
3368            tol: 1.0e-9,
3369            resume_from: Some(&checkpoint),
3370        })
3371        .expect("resumed multinomial solve must converge");
3372        let uninterrupted = fit_penalized_multinomial(MultinomialFitInputs {
3373            design: design.view(),
3374            y_one_hot: y.view(),
3375            penalty: penalty.view(),
3376            lambdas: lambdas.view(),
3377            row_weights: None,
3378            fisher_w_override: None,
3379            max_iter: 50,
3380            tol: 1.0e-9,
3381            resume_from: None,
3382        })
3383        .expect("uninterrupted multinomial solve must converge");
3384
3385        assert_eq!(resumed.iterations, uninterrupted.iterations);
3386        assert_eq!(
3387            resumed.coefficients_active,
3388            uninterrupted.coefficients_active
3389        );
3390        assert_eq!(
3391            resumed.penalized_neg_log_likelihood,
3392            uninterrupted.penalized_neg_log_likelihood,
3393        );
3394        assert_eq!(
3395            resumed.coefficient_covariance,
3396            uninterrupted.coefficient_covariance,
3397        );
3398    }
3399
3400    #[test]
3401    fn fisher_override_wrong_shape_is_rejected() {
3402        let (design, y, penalty, lambdas) = toy();
3403        let n = design.nrows();
3404        let m = y.ncols(); // K, not K-1 — deliberately wrong
3405        let bad = Array3::<f64>::zeros((n, m, m));
3406        let err = fit_penalized_multinomial(MultinomialFitInputs {
3407            design: design.view(),
3408            y_one_hot: y.view(),
3409            penalty: penalty.view(),
3410            lambdas: lambdas.view(),
3411            row_weights: None,
3412            fisher_w_override: Some(bad.view()),
3413            max_iter: 50,
3414            tol: 1.0e-9,
3415            resume_from: None,
3416        })
3417        .expect_err("wrong active-block shape must error");
3418        assert!(format!("{err}").contains("fisher_w_override shape"));
3419    }
3420
3421    /// #1101 regression: the fixed-λ inner solve now surfaces the joint Laplace
3422    /// coefficient covariance `H⁻¹`, and the multinomial predictor derives
3423    /// finite delta-method per-class probability standard errors from it. Before
3424    /// this change `MultinomialFitOutputs` carried NO covariance at all, so the
3425    /// covariance-dimension / predictor assertions below could not even compile
3426    /// (fail-before). Asserts, with un-weakened bounds:
3427    ///   1. covariance is `(P·(K−1))²`, all-finite, symmetric, and PSD (every
3428    ///      diagonal ≥ 0 and `vᵀΣv ≥ 0` on probe vectors);
3429    ///   2. the delta-method per-class probability SEs are finite and within
3430    ///      `[0, 1]` (a probability SE can never exceed the unit interval);
3431    ///   3. predicted probabilities are finite, in `[0, 1]`, and each row sums
3432    ///      to 1 (simplex).
3433    #[test]
3434    fn covariance_and_delta_method_se_are_finite_and_wellformed_1101() {
3435        let (design, y, penalty, lambdas) = toy();
3436        let p = design.ncols();
3437        let k = y.ncols();
3438        let m = k - 1;
3439        let d = p * m;
3440
3441        let fit = fit_penalized_multinomial(MultinomialFitInputs {
3442            design: design.view(),
3443            y_one_hot: y.view(),
3444            penalty: penalty.view(),
3445            lambdas: lambdas.view(),
3446            row_weights: None,
3447            fisher_w_override: None,
3448            max_iter: 50,
3449            tol: 1.0e-9,
3450            resume_from: None,
3451        })
3452        .expect("fit must succeed");
3453        // (1) Covariance shape, finiteness, symmetry.
3454        let cov = &fit.coefficient_covariance;
3455        assert_eq!(
3456            cov.dim(),
3457            (d, d),
3458            "covariance must be (P·(K−1))² = ({d},{d})"
3459        );
3460        for &v in cov.iter() {
3461            assert!(v.is_finite(), "covariance entry must be finite (got {v})");
3462        }
3463        for i in 0..d {
3464            for j in 0..d {
3465                let asym = (cov[[i, j]] - cov[[j, i]]).abs();
3466                assert!(
3467                    asym <= 1e-9 * (1.0 + cov[[i, j]].abs()),
3468                    "covariance must be symmetric at ({i},{j}): |Σ_ij − Σ_ji| = {asym:.3e}"
3469                );
3470            }
3471        }
3472        // PSD: diagonal ≥ 0 and quadratic forms on deterministic probe vectors
3473        // (unit axes and the all-ones vector) are non-negative. `H = XᵀWX + λS`
3474        // with W PSD (softmax Fisher) and S PSD (identity here) is positive
3475        // definite, so its inverse is PD; these probes must all be positive.
3476        for i in 0..d {
3477            assert!(
3478                cov[[i, i]] >= 0.0,
3479                "covariance diagonal[{i}] must be ≥ 0 (got {})",
3480                cov[[i, i]]
3481            );
3482        }
3483        let mut probes: Vec<Vec<f64>> = Vec::new();
3484        for i in 0..d {
3485            let mut e = vec![0.0_f64; d];
3486            e[i] = 1.0;
3487            probes.push(e);
3488        }
3489        probes.push(vec![1.0_f64; d]);
3490        for v in &probes {
3491            let mut q = 0.0_f64;
3492            for i in 0..d {
3493                for j in 0..d {
3494                    q += v[i] * cov[[i, j]] * v[j];
3495                }
3496            }
3497            assert!(q >= -1e-9, "covariance must be PSD: vᵀΣv = {q:.3e} < 0");
3498        }
3499
3500        // (2) & (3) Delta-method SEs and simplex probabilities on the training
3501        // design (any P-column matrix in the fitted basis works).
3502        let (probs, prob_se) = fit
3503            .predict_probabilities_with_se(design.view())
3504            .expect("delta-method SE must succeed");
3505        let n = design.nrows();
3506        assert_eq!(probs.dim(), (n, k));
3507        assert_eq!(prob_se.dim(), (n, k));
3508        for row in 0..n {
3509            let mut rowsum = 0.0_f64;
3510            for c in 0..k {
3511                let pc = probs[[row, c]];
3512                assert!(
3513                    pc.is_finite() && (0.0..=1.0).contains(&pc),
3514                    "prob[{row},{c}]={pc}"
3515                );
3516                rowsum += pc;
3517                let se = prob_se[[row, c]];
3518                assert!(
3519                    se.is_finite(),
3520                    "prob_se[{row},{c}] must be finite (got {se})"
3521                );
3522                assert!(
3523                    (0.0..=1.0).contains(&se),
3524                    "prob_se[{row},{c}] must be in [0,1] (got {se})"
3525                );
3526            }
3527            assert!(
3528                (rowsum - 1.0).abs() < 1e-9,
3529                "row {row} probabilities must sum to 1 (got {rowsum})"
3530            );
3531        }
3532    }
3533
3534    #[test]
3535    fn formula_outer_route_uses_exact_curvature_for_medium_d() {
3536        // The 2-smooth reference formula fit (K = 3, double-penalty terms) is
3537        // D = (K-1) * 2 terms * 2 penalties = 8 and needs exact curvature to
3538        // avoid over-smoothed lambda caps (#715 arm (a)).
3539        assert!(
3540            multinomial_formula_use_outer_hessian(8),
3541            "D=8 loaded multinomial fits need exact curvature to avoid over-smoothed lambda caps"
3542        );
3543        assert!(
3544            multinomial_formula_use_outer_hessian(12),
3545            "D=12 (3 double-penalty smooth terms, K=3) stays on exact curvature"
3546        );
3547    }
3548
3549    #[test]
3550    fn formula_outer_route_uses_exact_curvature_for_d16_penguin_fixture() {
3551        // Four k=10 penguin smooths (K = 3) are D = 16 under double-penalty
3552        // terms. They must reach the exact ARC route so the #1082 cost-stall
3553        // halt is available on the near-separable lambda-to-zero ridge.
3554        assert!(
3555            multinomial_formula_use_outer_hessian(16),
3556            "D=16 multinomial fits need exact ARC curvature for the #1082 stall halt"
3557        );
3558    }
3559
3560    #[test]
3561    fn formula_min_lambda_floor_is_continuous_and_information_scaled() {
3562        // Build a one-hot label matrix whose smallest class carries `count` rows.
3563        fn floor_for_min_count(count: usize) -> f64 {
3564            // Two classes: a large one (1000 rows) and a minority one (`count`).
3565            let n = 1000 + count;
3566            let mut y = Array2::<f64>::zeros((n, 2));
3567            for r in 0..1000 {
3568                y[[r, 0]] = 1.0;
3569            }
3570            for r in 1000..n {
3571                y[[r, 1]] = 1.0;
3572            }
3573            multinomial_formula_min_lambda(y.view())
3574        }
3575
3576        // The floor's endpoints are now DERIVED from a target prior strength in
3577        // pseudo-observations against the maximal per-observation softmax Fisher
3578        // information I₁ = ¼ (base = τ·I₁, sparse = τ_max·I₁). Pin them to the
3579        // previously fixture-calibrated values so the near-separable quality arms
3580        // (penguins, vgam softmax) — whose smallest class has n_c ≥ 50 — are
3581        // byte-for-byte unaffected: the derivation REDUCES TO the old constants
3582        // at the calibration point.
3583        let base = MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
3584        let sparse = MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX
3585            * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
3586        assert!(
3587            (base - 2.0e-4).abs() < 1e-18,
3588            "derived base floor must equal the calibrated 2e-4"
3589        );
3590        assert!(
3591            (sparse - 1.0e-3).abs() < 1e-18,
3592            "derived sparse floor must equal the calibrated 1e-3"
3593        );
3594
3595        // Well-supported (n_c >= n_ref=50) sits exactly at the base floor.
3596        assert!((floor_for_min_count(50) - base).abs() < 1e-18);
3597        assert!((floor_for_min_count(200) - base).abs() < 1e-18);
3598        // Very sparse (n_c <= n_ref·base/sparse = 10) clamps to the strong floor.
3599        assert!((floor_for_min_count(10) - sparse).abs() < 1e-18);
3600        assert!((floor_for_min_count(5) - sparse).abs() < 1e-18);
3601        // No cliff at the old hard threshold: 49 vs 50 differ by < 5% (the old
3602        // step jumped 5x). Floor is monotone non-increasing in support.
3603        let f49 = floor_for_min_count(49);
3604        let f50 = floor_for_min_count(50);
3605        assert!(
3606            f49 >= f50 && f49 <= f50 * 1.05,
3607            "floor must be continuous across c0, got {f49} vs {f50}"
3608        );
3609        let f25 = floor_for_min_count(25);
3610        assert!(
3611            f25 > f50 && f25 < floor_for_min_count(10),
3612            "mid-support floor must interpolate strictly between the two endpoints"
3613        );
3614
3615        // FIRST-PRINCIPLES SCALING: in the interpolating regime the floor equals
3616        // exactly τ·I₁·(n_ref/n_c) — the effective-pseudo-observation prior held
3617        // to a fixed fraction of the per-class data information n_c·I₁. Halving
3618        // the effective sample size doubles the floor (until the cap), and the
3619        // absolute value matches the closed-form n_c-scaled prior.
3620        for &n_c in &[12usize, 16, 20, 30, 40] {
3621            let expected = base * (MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT / n_c as f64);
3622            assert!(
3623                (floor_for_min_count(n_c) - expected).abs() < 1e-15,
3624                "floor at n_c={n_c} must be τ·I₁·n_ref/n_c = {expected}, got {}",
3625                floor_for_min_count(n_c)
3626            );
3627        }
3628        // Inverse scaling with effective sample size: n_c -> n_c/2 doubles the
3629        // floor inside the unclamped band (20 and 40 are both interior; 40 < 50
3630        // so it is scaled, 20 > 10 so it is not capped).
3631        assert!(
3632            (floor_for_min_count(20) - 2.0 * floor_for_min_count(40)).abs() < 1e-15,
3633            "floor must scale like 1/n_c (effective Fisher information) in the interior band"
3634        );
3635    }
3636
3637    #[test]
3638    fn formula_penalty_scale_tracks_softmax_fisher_curvature() {
3639        assert!(
3640            (multinomial_formula_penalty_scale(2) - 0.5).abs() < 1.0e-12,
3641            "binary-logit neutral-simplex curvature scale should remain at 1/2"
3642        );
3643        assert!(
3644            (multinomial_formula_penalty_scale(3) - 4.0 / 9.0).abs() < 1.0e-12,
3645            "three-class softmax penalties should be calibrated to 2*(K-1)/K^2"
3646        );
3647        assert!(
3648            multinomial_formula_penalty_scale(5) < multinomial_formula_penalty_scale(3),
3649            "active-class Fisher curvature decreases as the simplex gains classes"
3650        );
3651    }
3652
3653    #[test]
3654    fn fixed_lambda_multinomial_firth_keeps_complete_separation_finite() {
3655        // #1854: complete softmax separation used to be a HARD diagnostic
3656        // (`MultinomialSeparationDetected`). It now automatically engages the
3657        // Firth/Jeffreys proper prior (`½ log|I(β)|`, magic-by-default) so the fit
3658        // stays finite instead of running away — the same guarantee the formula
3659        // REML path already provided. The class regions are cleanly separated by
3660        // `x`, so the unbiased MLE is at infinity; the Firth-penalized fit must
3661        // still converge to a finite mode and recover the region structure.
3662        let n = 90;
3663        let design = Array2::<f64>::from_shape_fn((n, 2), |(row, col)| match col {
3664            0 => 1.0,
3665            _ => -3.0 + 6.0 * (row as f64) / ((n - 1) as f64),
3666        });
3667        let mut y = Array2::<f64>::zeros((n, 3));
3668        for row in 0..n {
3669            let x = design[[row, 1]];
3670            let class = if x < -1.0 {
3671                0
3672            } else if x > 1.0 {
3673                1
3674            } else {
3675                2
3676            };
3677            y[[row, class]] = 1.0;
3678        }
3679        let penalty = Array2::<f64>::zeros((2, 2));
3680        let lambdas = Array1::<f64>::zeros(2);
3681        let out = fit_penalized_multinomial(MultinomialFitInputs {
3682            design: design.view(),
3683            y_one_hot: y.view(),
3684            penalty: penalty.view(),
3685            lambdas: lambdas.view(),
3686            row_weights: None,
3687            fisher_w_override: None,
3688            max_iter: 80,
3689            tol: 1.0e-12,
3690            resume_from: None,
3691        })
3692        .expect("Firth/Jeffreys prior keeps the separated multinomial fit finite (#1854)");
3693        // Every coefficient is finite — the whole point of the Firth prior on the
3694        // separated (unpenalized) logit directions.
3695        for &b in out.coefficients_active.iter() {
3696            assert!(
3697                b.is_finite(),
3698                "Firth-penalized coefficients must be finite, got {b}"
3699            );
3700        }
3701        // Fitted probabilities remain a valid simplex per row.
3702        for row in 0..n {
3703            let mut mass = 0.0_f64;
3704            for c in 0..3 {
3705                let p = out.fitted_probabilities[[row, c]];
3706                assert!(
3707                    p.is_finite() && (0.0..=1.0 + 1e-9).contains(&p),
3708                    "row {row} class {c} probability {p} out of [0,1]"
3709                );
3710                mass += p;
3711            }
3712            assert!(
3713                (mass - 1.0).abs() < 1e-6,
3714                "row {row} probabilities must sum to 1, got {mass}"
3715            );
3716        }
3717        // The finite fit still recovers the separated structure: on a clearly
3718        // interior representative of each region the predicted class is correct.
3719        let predict = |x: f64| -> usize {
3720            let mut eta = [0.0_f64; 3];
3721            for a in 0..2 {
3722                eta[a] = out.coefficients_active[[0, a]] + out.coefficients_active[[1, a]] * x;
3723            }
3724            let mut best = 0usize;
3725            for c in 1..3 {
3726                if eta[c] > eta[best] {
3727                    best = c;
3728                }
3729            }
3730            best
3731        };
3732        assert_eq!(predict(-2.5), 0, "deep-left region should predict class 0");
3733        assert_eq!(predict(2.5), 1, "deep-right region should predict class 1");
3734        assert_eq!(predict(0.0), 2, "central region should predict class 2");
3735    }
3736
3737    #[test]
3738    fn formula_multinomial_accepts_finite_saturated_logits() {
3739        // A saturated-but-FINITE logit surface can be a valid formula REML mode
3740        // (the #715 penguins regime: bill/flipper cleanly separate the species,
3741        // so fitted logits can legitimately exceed ±25). `outer_converged ==
3742        // false` then signals only that the driver auto-escalated to never-fail
3743        // posterior sampling about that finite mode (gam#860), NOT a separation
3744        // artifact — the adapter must accept it, never raise
3745        // `MultinomialSeparationDetected`.
3746        let saturated_states = vec![
3747            ParameterBlockState {
3748                beta: Array1::from_vec(vec![1.0, 2.0]),
3749                eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
3750            },
3751            ParameterBlockState {
3752                beta: Array1::from_vec(vec![-1.0, 3.0]),
3753                eta: Array1::from_vec(vec![1.0, 25.5, -0.1]),
3754            },
3755        ];
3756        assert!(
3757            multinomial_formula_separation_diagnostic(17, 9, &saturated_states).is_none(),
3758            "a finite (even saturated, |eta|>25) formula optimum is a valid fit, \
3759             not a separation diagnostic"
3760        );
3761
3762        // Only a genuinely NON-FINITE logit — a NaN/Inf blow-up in the inner
3763        // linear algebra with no finite mode to sample about — is a real
3764        // formula-path failure.
3765        let blown_up = vec![
3766            ParameterBlockState {
3767                beta: Array1::from_vec(vec![1.0, 2.0]),
3768                eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
3769            },
3770            ParameterBlockState {
3771                beta: Array1::from_vec(vec![-1.0, 3.0]),
3772                eta: Array1::from_vec(vec![1.0, f64::INFINITY, -0.1]),
3773            },
3774        ];
3775        let err = multinomial_formula_separation_diagnostic(17, 9, &blown_up)
3776            .expect("a non-finite formula logit must raise the separation diagnostic");
3777        assert!(
3778            matches!(
3779                err,
3780                EstimationError::MultinomialSeparationDetected {
3781                    iteration: 17,
3782                    max_abs_eta,
3783                    active_class_index: 1,
3784                    row_index: 1,
3785                } if !max_abs_eta.is_finite()
3786            ),
3787            "expected typed multinomial separation diagnostic at the non-finite channel, got {err:?}"
3788        );
3789    }
3790
3791    #[test]
3792    fn separation_evidence_gate_arms_firth_only_on_blowup() {
3793        // Interior fit: finite logits well inside the saturation threshold ⇒ NO
3794        // separation evidence ⇒ the unbiased criterion's mode is accepted as-is
3795        // and the Firth/Jeffreys prior stays disarmed (#715 arm (a): no 1/K
3796        // shrinkage on well-identified data).
3797        let interior = vec![
3798            ParameterBlockState {
3799                beta: Array1::from_vec(vec![1.0, 2.0]),
3800                eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
3801            },
3802            ParameterBlockState {
3803                beta: Array1::from_vec(vec![-1.0, 3.0]),
3804                eta: Array1::from_vec(vec![1.0, -3.5, -0.1]),
3805            },
3806        ];
3807        assert!(
3808            multinomial_formula_separation_evidence(&interior).is_none(),
3809            "an interior finite mode must not arm the Firth refit"
3810        );
3811
3812        // Saturated but finite logits are valid formula-path modes on
3813        // near-separated real data. They must not arm the Firth refit because
3814        // the Jeffreys pull can over-regularize the held-out probabilities.
3815        let saturated = vec![
3816            ParameterBlockState {
3817                beta: Array1::from_vec(vec![1.0, 2.0]),
3818                eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
3819            },
3820            ParameterBlockState {
3821                beta: Array1::from_vec(vec![-1.0, 3.0]),
3822                eta: Array1::from_vec(vec![1.0, 25.5, -0.1]),
3823            },
3824        ];
3825        assert!(
3826            multinomial_formula_separation_evidence(&saturated).is_none(),
3827            "a finite saturated formula-mode logit must not arm the Firth refit"
3828        );
3829
3830        // Non-finite logit ⇒ inner blow-up along an unbounded direction ⇒
3831        // separation evidence.
3832        let blown_up = vec![ParameterBlockState {
3833            beta: Array1::from_vec(vec![1.0, 2.0]),
3834            eta: Array1::from_vec(vec![0.2, f64::NAN, -7.0]),
3835        }];
3836        let evidence = multinomial_formula_separation_evidence(&blown_up)
3837            .expect("a non-finite logit is separation evidence");
3838        assert!(
3839            evidence.contains("non-finite logit") && evidence.contains("row 1"),
3840            "evidence must name the non-finite logit, got {evidence}"
3841        );
3842
3843        // Large finite logits below the fixed-lambda diagnostic threshold are
3844        // likewise accepted on the formula path.
3845        let near = vec![ParameterBlockState {
3846            beta: Array1::from_vec(vec![1.0, 2.0]),
3847            eta: Array1::from_vec(vec![0.2, 24.9, -24.9]),
3848        }];
3849        assert!(
3850            multinomial_formula_separation_evidence(&near).is_none(),
3851            "logits below the saturation threshold must not arm the Firth refit"
3852        );
3853    }
3854
3855    #[test]
3856    fn unresolved_probe_evidence_arms_firth_on_saturated_finite_logits() {
3857        let saturated = vec![
3858            ParameterBlockState {
3859                beta: Array1::from_vec(vec![1.0, 2.0]),
3860                eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
3861            },
3862            ParameterBlockState {
3863                beta: Array1::from_vec(vec![-1.0, 3.0]),
3864                eta: Array1::from_vec(vec![1.0, 25.5, -0.1]),
3865            },
3866        ];
3867
3868        assert!(
3869            multinomial_formula_separation_evidence(&saturated).is_none(),
3870            "a converged finite saturated formula optimum remains unbiased"
3871        );
3872        let evidence = multinomial_formula_unresolved_probe_separation_evidence(&saturated)
3873            .expect("a non-converged saturated probe should arm the Firth refit");
3874        assert!(
3875            evidence.contains("separation-scale finite logit")
3876                && evidence.contains("row 1")
3877                && evidence.contains("active class 1"),
3878            "unresolved-probe evidence should name the saturated channel, got {evidence}"
3879        );
3880
3881        let near = vec![ParameterBlockState {
3882            beta: Array1::from_vec(vec![1.0, 2.0]),
3883            eta: Array1::from_vec(vec![0.2, 24.9, -24.9]),
3884        }];
3885        assert!(
3886            multinomial_formula_unresolved_probe_separation_evidence(&near).is_none(),
3887            "finite logits below the separation threshold still get the full unbiased retry"
3888        );
3889    }
3890
3891    #[test]
3892    fn scaled_fisher_override_changes_first_step() {
3893        // Curvature scaled by 4× shrinks the first Newton step relative to the
3894        // analytic fit, so a single-iteration fit must differ.
3895        let (design, y, penalty, lambdas) = toy();
3896        let n = design.nrows();
3897        let m = y.ncols() - 1;
3898        // Analytic block at β = 0: p_a = 1/K = 1/3, so diag = p_a(1−p_a),
3899        // off-diag = −p_a p_b. Scale that exact block by 4.
3900        let pk = 1.0 / (y.ncols() as f64);
3901        let mut over = Array3::<f64>::zeros((n, m, m));
3902        for row in 0..n {
3903            for a in 0..m {
3904                for b in 0..m {
3905                    let analytic = if a == b { pk * (1.0 - pk) } else { -pk * pk };
3906                    over[[row, a, b]] = 4.0 * analytic;
3907                }
3908            }
3909        }
3910        let likelihood =
3911            MultinomialLogitLikelihood::with_classes(y.ncols()).expect("test class count is valid");
3912        let scaled = fit_penalized_vector_glm(
3913            PenalizedVectorGlmInputs {
3914                design: design.view(),
3915                y: y.view(),
3916                penalty: penalty.view(),
3917                lambdas: lambdas.view(),
3918                fisher_w_override: Some(over.view()),
3919                max_iter: 1,
3920                tol: 1.0e-9,
3921                class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Centered,
3922                resume_from: None,
3923            },
3924            &likelihood,
3925            "multinomial scaled-curvature first-step test",
3926        )
3927        .expect("scaled-curvature engine step must be finite");
3928        let analytic = fit_penalized_vector_glm(
3929            PenalizedVectorGlmInputs {
3930                design: design.view(),
3931                y: y.view(),
3932                penalty: penalty.view(),
3933                lambdas: lambdas.view(),
3934                fisher_w_override: None,
3935                max_iter: 1,
3936                tol: 1.0e-9,
3937                class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Centered,
3938                resume_from: None,
3939            },
3940            &likelihood,
3941            "multinomial analytic-curvature first-step test",
3942        )
3943        .expect("analytic-curvature engine step must be finite");
3944        let checkpoint_coefficients = |solve| match solve {
3945            VectorGlmSolve::Converged(fit) => fit.coefficients,
3946            VectorGlmSolve::Stalled(stall) => stall.coefficients,
3947        };
3948        let scaled = checkpoint_coefficients(scaled);
3949        let analytic = checkpoint_coefficients(analytic);
3950        let differs = scaled
3951            .iter()
3952            .zip(analytic.iter())
3953            .any(|(a, b)| (a - b).abs() > 1.0e-6);
3954        assert!(differs, "scaled curvature must change the first step");
3955    }
3956}
3957
3958#[cfg(test)]
3959mod separation_firth_tests {
3960    //! Regression for #1854: on (quasi-)perfect separation the fixed-λ direct
3961    //! multinomial solve must engage the Firth/Jeffreys penalty and return a
3962    //! finite, converged, well-behaved fit instead of hard-erroring with
3963    //! `MultinomialSeparationDetected`.
3964    use super::*;
3965
3966    /// A perfectly linearly separable 3-class problem with an UNPENALIZED design
3967    /// (`S = 0`), so no smoothing `λ` can bound the saturated logits — only the
3968    /// Firth prior `½ log det I(β)` keeps the estimate finite. The unbiased MLE
3969    /// here runs `|η| → ∞` (separation), which is exactly the #1854 trigger.
3970    fn separated_three_class() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
3971        let n = 21;
3972        let p = 2; // intercept + ordering covariate x
3973        let k = 3;
3974        let mut design = Array2::<f64>::zeros((n, p));
3975        let mut y = Array2::<f64>::zeros((n, k));
3976        for i in 0..n {
3977            let x = -3.0 + 6.0 * (i as f64) / ((n - 1) as f64);
3978            design[[i, 0]] = 1.0;
3979            design[[i, 1]] = x;
3980            let cls = if x < -1.0 {
3981                0
3982            } else if x < 1.0 {
3983                1
3984            } else {
3985                2
3986            };
3987            y[[i, cls]] = 1.0;
3988        }
3989        // S = 0: no smoothing direction can bound the separated logits.
3990        let penalty = Array2::<f64>::zeros((p, p));
3991        let lambdas = Array1::<f64>::from_elem(k - 1, 1.0);
3992        (design, y, penalty, lambdas)
3993    }
3994
3995    #[test]
3996    fn separation_engages_firth_finite_converged_fit() {
3997        let (design, y, penalty, lambdas) = separated_three_class();
3998        let out = fit_penalized_multinomial(MultinomialFitInputs {
3999            design: design.view(),
4000            y_one_hot: y.view(),
4001            penalty: penalty.view(),
4002            lambdas: lambdas.view(),
4003            row_weights: None,
4004            fisher_w_override: None,
4005            max_iter: 300,
4006            tol: 1e-10,
4007            resume_from: None,
4008        })
4009        .expect("separated multinomial must engage Firth and return a fit, not error");
4010
4011        assert!(
4012            out.coefficients_active.iter().all(|v| v.is_finite()),
4013            "all coefficients must be finite under the Firth prior"
4014        );
4015        assert!(out.deviance.is_finite(), "deviance must be finite");
4016
4017        // The runaway MLE would drive fitted probabilities to the {0,1} boundary;
4018        // the Firth prior keeps them strictly interior.
4019        for v in out.fitted_probabilities.iter() {
4020            assert!(
4021                *v > 0.0 && *v < 1.0,
4022                "Firth fit must stay interior, got p={v}"
4023            );
4024        }
4025
4026        // Perfect separation ⇒ every training row classified to its true class.
4027        let n = design.nrows();
4028        let k = y.ncols();
4029        for i in 0..n {
4030            let mut best = 0usize;
4031            for c in 1..k {
4032                if out.fitted_probabilities[[i, c]] > out.fitted_probabilities[[i, best]] {
4033                    best = c;
4034                }
4035            }
4036            let truth = (0..k)
4037                .find(|&c| y[[i, c]] == 1.0)
4038                .expect("one-hot truth class");
4039            assert_eq!(best, truth, "row {i} misclassified under separation");
4040        }
4041    }
4042
4043    #[test]
4044    fn separation_firth_returns_finite_wellshaped_covariance() {
4045        // Distinct angle: the Firth separation path must also expose a finite,
4046        // correctly-shaped (P·M × P·M) Laplace coefficient covariance — the
4047        // downstream SE machinery consumes it. A runaway MLE would have a
4048        // singular (non-invertible) information here.
4049        let (design, y, penalty, lambdas) = separated_three_class();
4050        let p = design.ncols();
4051        let k = y.ncols();
4052        let m = k - 1;
4053        let out = fit_penalized_multinomial(MultinomialFitInputs {
4054            design: design.view(),
4055            y_one_hot: y.view(),
4056            penalty: penalty.view(),
4057            lambdas: lambdas.view(),
4058            row_weights: None,
4059            fisher_w_override: None,
4060            max_iter: 300,
4061            tol: 1e-10,
4062            resume_from: None,
4063        })
4064        .expect("separated multinomial must return a Firth fit");
4065
4066        assert_eq!(
4067            out.coefficient_covariance.dim(),
4068            (p * m, p * m),
4069            "covariance must be P·M square"
4070        );
4071        assert!(
4072            out.coefficient_covariance.iter().all(|v| v.is_finite()),
4073            "Firth covariance entries must be finite"
4074        );
4075        // A genuine Laplace covariance is PSD ⇒ non-negative diagonal.
4076        for i in 0..(p * m) {
4077            assert!(
4078                out.coefficient_covariance[[i, i]] >= -1e-9,
4079                "covariance diagonal must be non-negative, got {}",
4080                out.coefficient_covariance[[i, i]]
4081            );
4082        }
4083    }
4084
4085    #[test]
4086    fn firth_solver_rejects_a_truncated_iterate() {
4087        // #2066 / SPEC 20 (convergence honesty): the Firth Newton loop may only
4088        // construct a fit after certifying stationarity. Before the fix a
4089        // truncated solve returned coefficients and covariance behind a false
4090        // `converged` flag; now budget exhaustion is a typed error carrying the
4091        // iteration count and objective evidence.
4092        //
4093        // Angle: run the SAME separated problem that converges under a full
4094        // budget (`separation_engages_firth_finite_converged_fit`) but starve the
4095        // iteration budget so it provably cannot reach the interior Firth mode.
4096        // The honest outcome is a typed error, not an inspectable fit.
4097        let (design, y, penalty, lambdas) = separated_three_class();
4098
4099        let truncated = fit_penalized_multinomial_firth_fallback(
4100            design.view(),
4101            y.view(),
4102            penalty.view(),
4103            lambdas.view(),
4104            None,
4105            1, // one Newton iteration — far from the separated mode
4106            1e-12,
4107            None,
4108        )
4109        .expect_err("a one-iteration Firth solve must not mint a fit");
4110        let checkpoint = match truncated {
4111            EstimationError::FixedLambdaNewtonDidNotConverge {
4112                objective_value,
4113                stationarity,
4114                checkpoint,
4115                ..
4116            } => {
4117                assert!(objective_value.is_finite());
4118                assert_eq!(stationarity.kind, FixedLambdaResidualKind::NewtonDecrement);
4119                assert_eq!(checkpoint.stage(), FixedLambdaSolverStage::MultinomialFirth);
4120                assert_eq!(checkpoint.completed_iterations(), 1);
4121                checkpoint
4122            }
4123            other => panic!("unexpected Firth interruption error: {other}"),
4124        };
4125
4126        let resumed = fit_penalized_multinomial(MultinomialFitInputs {
4127            design: design.view(),
4128            y_one_hot: y.view(),
4129            penalty: penalty.view(),
4130            lambdas: lambdas.view(),
4131            row_weights: None,
4132            fisher_w_override: None,
4133            max_iter: 299,
4134            tol: 1e-10,
4135            resume_from: Some(&checkpoint),
4136        })
4137        .expect("Firth checkpoint must resume to the certified mode");
4138
4139        // Contrast: with a full budget the same problem does reach stationarity
4140        // and returns the convergence-only result type.
4141        let uninterrupted = fit_penalized_multinomial_firth_fallback(
4142            design.view(),
4143            y.view(),
4144            penalty.view(),
4145            lambdas.view(),
4146            None,
4147            300,
4148            1e-10,
4149            None,
4150        )
4151        .expect("Firth fallback must converge under a full budget");
4152        assert_eq!(resumed.iterations, uninterrupted.iterations);
4153        assert_eq!(
4154            resumed.coefficients_active,
4155            uninterrupted.coefficients_active
4156        );
4157        assert_eq!(
4158            resumed.penalized_neg_log_likelihood,
4159            uninterrupted.penalized_neg_log_likelihood,
4160        );
4161        assert_eq!(
4162            resumed.coefficient_covariance,
4163            uninterrupted.coefficient_covariance,
4164        );
4165    }
4166}
4167
4168#[cfg(test)]
4169mod reference_class_invariance_tests {
4170    //! Regression for #1587: a penalized multinomial-logit GAM fit must be
4171    //! invariant to which class is the (arbitrary) softmax reference/baseline.
4172    //!
4173    //! The production REML path (`fit_penalized_multinomial_formula`) reference-
4174    //! codes the `K` classes (the last sorted label is the baseline) and, with
4175    //! the legacy `Diagonal` penalty metric, penalizes only the `K−1`
4176    //! reference-anchored ALR contrasts `½ Σ_a λ_a β_aᵀ S β_a`. Relabeling the
4177    //! response so a *different* class sorts last penalizes a different frame of
4178    //! log-odds contrasts, so the predicted probabilities drift (~1e-2 absolute)
4179    //! even though they are mathematically independent of the reference choice.
4180    //!
4181    //! This test fits the SAME 3-class softmax sample under three cyclic
4182    //! relabelings — each making a different original class the baseline —
4183    //! realigns the predicted probability columns back to the original class
4184    //! identities, and asserts the cross-labeling drift is below `1e-3`
4185    //! (the defect is ~1e-2; refitting the same labeling twice agrees to
4186    //! ~1e-12). It is the Rust-level sibling of
4187    //! `tests/bug_hunt_multinomial_fit_depends_on_reference_class_test.py`.
4188
4189    use super::*;
4190    use gam_data::load_dataset_projected;
4191    use std::fmt::Write as _;
4192    use std::fs;
4193    use tempfile::tempdir;
4194
4195    /// Deterministic `splitmix64` → `[0,1)` uniform stream (no external RNG dep;
4196    /// the only requirement is a well-distributed, reproducible draw).
4197    struct SplitMix64(u64);
4198    impl SplitMix64 {
4199        fn next_u64(&mut self) -> u64 {
4200            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
4201            let mut z = self.0;
4202            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
4203            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
4204            z ^ (z >> 31)
4205        }
4206        fn unit(&mut self) -> f64 {
4207            // 53-bit mantissa uniform in [0, 1).
4208            (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
4209        }
4210    }
4211
4212    /// Draw a clean 3-class softmax regression sample (the issue's generator).
4213    /// Returns `(x, class)` with integer classes `0/1/2`.
4214    fn sample_classes(seed: u64, n: usize) -> (Vec<f64>, Vec<usize>) {
4215        let mut rng = SplitMix64(seed.wrapping_add(0x1234_5678));
4216        let mut x = Vec::with_capacity(n);
4217        let mut cls = Vec::with_capacity(n);
4218        for _ in 0..n {
4219            let xi = -2.0 + 4.0 * rng.unit();
4220            let eta = [0.5 + 0.8 * xi, -0.3 - 0.5 * xi, 0.0];
4221            let mut p = [eta[0].exp(), eta[1].exp(), eta[2].exp()];
4222            let s: f64 = p.iter().sum();
4223            for v in &mut p {
4224                *v /= s;
4225            }
4226            // Inverse-CDF draw into one of the 3 classes.
4227            let u = rng.unit();
4228            let c = if u < p[0] {
4229                0
4230            } else if u < p[0] + p[1] {
4231                1
4232            } else {
4233                2
4234            };
4235            x.push(xi);
4236            cls.push(c);
4237        }
4238        (x, cls)
4239    }
4240
4241    /// Build an `EncodedDataset` with columns `x` (numeric) and `y`
4242    /// (categorical, from the given string labels) by round-tripping a CSV.
4243    fn dataset_xy(
4244        dir: &std::path::Path,
4245        tag: &str,
4246        x: &[f64],
4247        y: &[String],
4248    ) -> gam_data::EncodedDataset {
4249        let path = dir.join(format!("data_{tag}.csv"));
4250        let mut csv = String::from("x,y\n");
4251        for (xi, yi) in x.iter().zip(y.iter()) {
4252            writeln!(csv, "{xi},{yi}").unwrap();
4253        }
4254        fs::write(&path, csv).expect("write training csv");
4255        load_dataset_projected(&path, &["x".to_string(), "y".to_string()])
4256            .expect("load training dataset")
4257    }
4258
4259    /// Fit `y ~ s(x)` under the relabeling `name_map` (original class `c` gets
4260    /// label `name_map[c]`), predict on `grid`, and return the predicted
4261    /// probabilities **realigned to the original class order** 0/1/2, shape
4262    /// `(grid.len(), 3)`.
4263    fn fit_predict_aligned(
4264        dir: &std::path::Path,
4265        tag: &str,
4266        x: &[f64],
4267        cls: &[usize],
4268        name_map: [&str; 3],
4269        grid: &[f64],
4270    ) -> Array2<f64> {
4271        let labels: Vec<String> = cls.iter().map(|&c| name_map[c].to_string()).collect();
4272        let train = dataset_xy(dir, tag, x, &labels);
4273        let config = FitConfig::default();
4274        let model = fit_penalized_multinomial_formula(&MultinomialFitRequest {
4275            init_lambda: 1.0,
4276            max_iter: 60,
4277            tol: 1e-6,
4278            ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
4279        })
4280        .expect("multinomial formula fit must succeed");
4281
4282        // Predict on the grid. The categorical `y` column is not needed for
4283        // prediction, but the schema is simplest if we supply a dummy.
4284        let grid_y: Vec<String> = grid.iter().map(|_| name_map[0].to_string()).collect();
4285        let grid_ds = dataset_xy(dir, &format!("{tag}_grid"), grid, &grid_y);
4286        let probs = predict_multinomial_formula(&model, &grid_ds)
4287            .expect("multinomial predict must succeed");
4288
4289        // `model.class_levels` is the sorted label order; the column for original
4290        // class `c` is at the rank of `name_map[c]` among the sorted labels.
4291        let mut sorted: Vec<&str> = name_map.to_vec();
4292        sorted.sort_unstable();
4293        let col_of_orig: Vec<usize> = (0..3)
4294            .map(|c| sorted.iter().position(|l| *l == name_map[c]).unwrap())
4295            .collect();
4296        // Sanity: the model's class_levels must match the sorted labels.
4297        assert_eq!(
4298            model.class_levels,
4299            sorted.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
4300            "class_levels must be the sorted label order"
4301        );
4302        let n = grid.len();
4303        let mut aligned = Array2::<f64>::zeros((n, 3));
4304        for r in 0..n {
4305            for c in 0..3 {
4306                aligned[[r, c]] = probs[[r, col_of_orig[c]]];
4307            }
4308        }
4309        aligned
4310    }
4311
4312    fn max_abs_diff(a: &Array2<f64>, b: &Array2<f64>) -> f64 {
4313        a.iter()
4314            .zip(b.iter())
4315            .map(|(p, q)| (p - q).abs())
4316            .fold(0.0_f64, f64::max)
4317    }
4318
4319    // gam#1587: now that the reference-symmetric centered `M⊗S_t` joint penalty
4320    // is wired through the custom-family outer REML loop (per-eval
4321    // `JointPenaltyBundle` + outer penalty_coords/logdet/operator), the
4322    // production multinomial fit is invariant to the arbitrary reference class,
4323    // so this guard runs by default (the opt-in skip attribute it carried while
4324    // the fix was pending is also forbidden by the build.rs ban-scanner). It is
4325    // an end-to-end fit guard (a handful of full softmax `y ~ s(x)` fits) —
4326    // slower than a unit test but a true production-path regression.
4327    #[test]
4328    fn multinomial_fit_is_invariant_to_reference_class_1587() {
4329        let td = tempdir().expect("tempdir");
4330        let dir = td.path();
4331        // The reference-class drift is STRUCTURAL (it does not shrink with n, see
4332        // the issue table), so a modest n exposes it just as cleanly as n=900
4333        // while keeping this an affordable CI guard.
4334        let (x, cls) = sample_classes(0, 300);
4335        let grid: Vec<f64> = (0..7).map(|i| -1.5 + 3.0 * (i as f64) / 6.0).collect();
4336
4337        // Three labelings that each make a DIFFERENT original class the baseline
4338        // (the class whose label sorts LAST is the reference K−1):
4339        //   ["A","B","C"] → ref = class 2
4340        //   ["B","C","A"] → ref = class 1
4341        //   ["C","A","B"] → ref = class 0
4342        let a = fit_predict_aligned(dir, "abc", &x, &cls, ["A", "B", "C"], &grid);
4343        let b = fit_predict_aligned(dir, "bca", &x, &cls, ["B", "C", "A"], &grid);
4344        let c = fit_predict_aligned(dir, "cab", &x, &cls, ["C", "A", "B"], &grid);
4345
4346        // Refitting the SAME labeling twice must agree to ~machine precision —
4347        // this isolates optimizer noise from the structural reference drift.
4348        let a2 = fit_predict_aligned(dir, "abc2", &x, &cls, ["A", "B", "C"], &grid);
4349        let refit_noise = max_abs_diff(&a, &a2);
4350        assert!(
4351            refit_noise < 1e-6,
4352            "refitting the same labeling must be deterministic (got {refit_noise:.3e})"
4353        );
4354
4355        let drift = max_abs_diff(&a, &b)
4356            .max(max_abs_diff(&a, &c))
4357            .max(max_abs_diff(&b, &c));
4358        assert!(
4359            drift < 1e-3,
4360            "predicted probabilities must be invariant to the reference class; \
4361             cross-labeling drift = {drift:.3e} (refit noise = {refit_noise:.3e})"
4362        );
4363    }
4364}