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