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, CustomFamily, 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_posterior::{
76    MultinomialPosteriorIntegrationControl, integrate_multinomial_design_moments,
77    softmax_with_reference,
78};
79use crate::multinomial_reml::MultinomialFamily;
80use crate::penalized_vector_glm::{
81    PenalizedVectorGlmInputs, VectorGlmResume, VectorGlmSolve, fit_penalized_vector_glm,
82};
83use crate::vector_response::{MultinomialLogitLikelihood, validate_multinomial_simplex};
84use gam_data::ColumnKindTag;
85use gam_data::EncodedDataset;
86use gam_problem::{
87    FixedLambdaCheckpoint, FixedLambdaResidualKind, FixedLambdaSolverStage, FixedLambdaStallReason,
88    FixedLambdaStationarityEvidence, ResponseColumnKind,
89};
90use gam_runtime::resource::ProblemHints;
91/// The covariance-definition axis, re-exported so a caller of this module's
92/// predict surface names the same enum `gam-predict` and the CLI do rather than
93/// reaching across crates for it.
94pub use gam_solve::model_types::InferenceCovarianceMode;
95use gam_terms::inference::formula_dsl::parse_formula;
96use gam_terms::smooth::{
97    PenaltyBlockInfo, TermCollectionDesign, TermCollectionSpec, build_term_collection_design,
98};
99use gam_terms::term_builder::resolve_role_col;
100use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayView3};
101use opt::{BacktrackConfig, backtracking_line_search};
102use serde::{Deserialize, Serialize};
103use std::convert::Infallible;
104use std::sync::Arc;
105
106/// Solver-only numerical stabilization floor for the formula-driven
107/// multinomial REML inner solve (gam#747).
108///
109/// Installed with [`RidgePolicy::solver_only`](gam_problem::RidgePolicy::solver_only)
110/// so it stabilizes the inner joint-Newton **linear solve** but never enters
111/// the REML objective, the penalty log-determinant, or the Laplace Hessian.
112///
113/// What it does: the multinomial smoothing penalties are rank-deficient by
114/// design (each smooth carries an unpenalized polynomial null space) and the
115/// formula may add a fully unpenalized parametric term (`x3` / `body_mass`). On
116/// near-separable hard labels the softmax curvature is ill-conditioned along
117/// those directions, so the bare Newton step `H⁻¹∇` is huge. Lifting the
118/// smallest Hessian eigenvalue to `δ` bounds the step (`‖(H+δI)⁻¹∇‖ ≤ ‖∇‖/δ`),
119/// keeping the screening iterates finite without poisoning the softmax with
120/// `inf − inf = NaN`.
121///
122/// What it deliberately does NOT do: it adds no `½·δ·‖β‖²` term to the
123/// objective and no `δ`-shift to the REML log-determinant. The earlier
124/// `explicit_stabilization_pospart` policy folded both into the criterion,
125/// which made `1e-4` a fixed-λ Gaussian prior that shrank every identified
126/// coefficient off the MLE and biased smoothing-parameter selection — a value
127/// that had to be tuned *between* under-stabilization (NaN seeds) and
128/// over-shrinkage (lost VGAM match). As a solver-only floor that tradeoff is
129/// gone: the over-shrinkage failure mode cannot occur (nothing is shrunk), the
130/// optimized objective is the true penalized REML criterion, and the floor
131/// only has to be large enough to keep the linear algebra finite.
132///
133/// The separation defect (#753) is no longer this floor's job. If the
134/// multinomial MLE is genuinely at infinity for an unpenalized/null-space
135/// direction (complete/quasi-complete separation), no solver floor makes that
136/// direction's estimate finite. The formula REML path arms the full-span
137/// Jeffreys/Firth correction CONDITIONALLY — only on separation evidence (see
138/// [`multinomial_formula_penalized_separation_evidence`] and the two-attempt logic
139/// in [`fit_penalized_multinomial_formula`]) — so an interior, well-identified
140/// fit optimizes the unbiased penalized-REML criterion with no Firth shrinkage
141/// toward the uniform simplex, while a finite-but-Fisher-underidentified or
142/// non-finite geometry gets the proper prior that is the only thing able to
143/// bound its penalty-null directions (#715/#2612 real-data arm). The bare
144/// fixed-λ inner driver
145/// [`fit_penalized_multinomial`] (no outer REML, no Jeffreys term) surfaces the
146/// explicit `MultinomialSeparationDetected` diagnostic for the path that has no
147/// proper prior to lean on.
148const MULTINOMIAL_FORMULA_RIDGE_FLOOR: f64 = 1.0e-4;
149
150/// Inner joint-Newton KKT tolerance for the multinomial formula path.
151///
152/// The softmax Fisher weight `W = diag(p) − ppᵀ` collapses on saturated rows,
153/// so near-separable fits (penguins, #715) reach the OBJECTIVE's f64 noise
154/// floor before the default `inner_tol = 1e-6` KKT target: measured on the
155/// penguins arm (standardized columns), the trust region collapses to 1e-12
156/// with per-attempt objective changes of ~+2e-9 on |obj| ≈ 1e2 (≈ 1e-11
157/// relative — pure rounding) while the KKT residual plateaus at 2.8e-5–9.4e-5
158/// against a scaled tolerance of ~1.9e-5. Demanding a residual below the
159/// floating-point noise floor is certifiable-never: every eval is rejected by
160/// the stall guard and the whole fit fails. `1e-5` certifies the measured
161/// plateaus while still resolving β to ~1e-6 in the relevant metric — the
162/// LAML criterion consumes β̂ with error O(residual²/curvature), far below
163/// any quantity the outer ρ-search can read.
164const MULTINOMIAL_FORMULA_INNER_TOL: f64 = 1.0e-5;
165
166/// Formula-adapter penalty calibration for multinomial softmax REML.
167///
168/// The term builder's normalized penalties are calibrated on single-response
169/// Gaussian-style score curvature. A reference-coded softmax class block sees
170/// per-row active-class Fisher diagonal `p_a(1-p_a)` plus negative cross-class
171/// coupling. At the neutral simplex (`p_k = 1/K`) the active diagonal is
172/// `(K-1)/K²`, so the binary-logit calibration is `2·(K-1)/K² = 1/2` and the
173/// three-class calibration is `4/9` rather than the historical hard-coded
174/// `1/2`. Making the scale a function of `K` keeps the physical smoothness
175/// prior tied to the likelihood curvature instead of over-penalizing every
176/// class as the simplex gains categories.
177fn multinomial_formula_penalty_scale(n_classes: usize) -> f64 {
178    let k = n_classes.max(2) as f64;
179    2.0 * (k - 1.0) / (k * k)
180}
181
182/// Largest smoothing-parameter dimension where exact dense outer curvature is
183/// still worth paying for multinomial formula fits.
184///
185/// `D = (K - 1) * n_penalties`. Medium-size loaded models use exact curvature
186/// so the optimizer does not wander into over-smoothed lambda caps on
187/// near-boundary softmax surfaces. The threshold was originally calibrated at
188/// `D <= 6` when each `s()` term carried ONE penalty; the double-penalty
189/// migration (wiggliness + null-space shrinkage per term, mgcv `select=TRUE`
190/// semantics) doubled `D` for the SAME models, silently flipping the
191/// reference formula fits (2 smooths, K = 3: old `D = 4`, now `D = 8`) onto
192/// the gradient-only route — where the #715 quality arm showed every
193/// wiggliness ρ driven onto the ±10 box bound (smooths collapsed toward their
194/// polynomial null space, truth-RMSE behind VGAM). `12 = 2 × 6` preserves the
195/// original classification boundary under the doubled penalty count while
196/// keeping the four-smooth penguin species quality fixture on the exact ARC
197/// path: that model is `D = 16`, and first-order BFGS can cycle along the
198/// near-separable lambda-to-zero ridge until the wall-clock budget expires
199/// (#1082). ARC observes the same exact curvature and can halt through the
200/// bound-aware cost-stall guard once the REML surface stops making useful
201/// progress.
202///
203/// CORRECTED 2026-08-08 (#2612), 16 -> 24, WITHOUT MOVING THE CALIBRATION POINT.
204/// The value above was chosen so the four-smooth penguin fixture lands on the
205/// exact path, and the `D = 16` it quotes for that fixture was computed as
206/// `(K-1) * n_penalties`. That has not been the number of outer coordinates
207/// since #1587: `equivariant_class_penalty_specs` emits one spec per class per
208/// penalty component whenever `K > 2`, so the same fixture is `8 * 3 = 24`, not
209/// `2 * 8 = 16` — confirmed against the refusal's own `last_evaluated_rho`,
210/// which carries 24 entries. The gate now reads
211/// `MultinomialFamily::joint_smoothing_dimension()`, and the threshold is the
212/// SAME fixture re-read with a corrected ruler rather than a new number: at
213/// `K = 3` the classification is unchanged (`3n <= 24` and `2n <= 16` are both
214/// `n <= 8` components), and where it differs at other `K` it differs by
215/// admitting MORE exact curvature, which is the side this constant's own
216/// rationale says is safe ("medium-D formula fits need exact curvature to keep
217/// lambda selection away from over-smoothed caps").
218const MULTINOMIAL_EXACT_OUTER_HESSIAN_MAX_DIM: usize = 24;
219
220fn multinomial_formula_use_outer_hessian(total_rho_dim: usize) -> bool {
221    total_rho_dim <= MULTINOMIAL_EXACT_OUTER_HESSIAN_MAX_DIM
222}
223
224/// Logit magnitude beyond which fitted probabilities are saturated at ordinary
225/// double precision diagnostic scale. The bare fixed-λ driver has no outer REML
226/// state and still uses this threshold to reject a non-converged saturated
227/// iterate as a separation artifact. The formula REML path does not use this as
228/// a Firth trigger: with smoothing parameters selected, a finite saturated
229/// surface can be the valid near-separated optimum that should be scored
230/// directly.
231const MULTINOMIAL_SEPARATION_ETA_THRESHOLD: f64 = 25.0;
232
233/// Calibrated convergence tolerance for the OUTER REML/LAML smoothing-parameter
234/// search on the formula multinomial path. Matches the primary GLM REML outer
235/// (`solver::fit_orchestration::materialize` uses `tol = 1e-7`, mirrored by the
236/// `LOG_LAMBDA_TOL` / `KKT_TOL_*` constants across the REML stack): tight enough
237/// that the selected λ reaches the genuine REML optimum (the recovered
238/// probability surface matches the mature reference), loose enough that the
239/// optimizer does not grind surface-irrelevant ρ digits down to the inner KKT
240/// scale (the #1082 wall-clock overrun). The caller's `tol` is floored at this
241/// value for the OUTER loop, while it continues to drive the INNER joint-Newton
242/// KKT target unchanged.
243const MULTINOMIAL_OUTER_REML_TOL: f64 = 1e-7;
244
245/// Per-observation softmax Fisher-information scale for the λ-floor units.
246///
247/// The penalty enters the criterion as `½ λ βᵀ S β` with a Frobenius-normalized
248/// `S` (`‖S‖_F = 1`, see the term-builder calibration referenced by
249/// [`multinomial_formula_penalty_scale`]), so the ridge `λ S` is directly
250/// comparable to data Fisher information. One observation contributes softmax
251/// information `p(1−p)` in a class's logit direction, which is bounded by the
252/// logistic peak `p(1−p) ≤ ¼` at `p = ½`. Using this maximal per-observation
253/// information as the unit makes the floor's strength interpretable as a count
254/// of equivalent **pseudo-observations** of prior: a ridge that equals
255/// `τ · ¼ · ‖S‖_F` carries the same logit-direction curvature as `τ` real rows
256/// sitting at the most-informative point of the likelihood. This scale is
257/// `K`-independent on purpose — the `K`-dependence of the softmax block
258/// curvature already lives in the penalty matrix via
259/// [`multinomial_formula_penalty_scale`], so the floor (a bound on the
260/// multiplier of that already-scaled penalty) must not double-count it.
261const MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS: f64 = 0.25;
262
263/// Target prior strength of the λ-floor, in pseudo-observations, for a
264/// WELL-SUPPORTED class. The floor holds the unbiased REML optimizer off the
265/// zero-penalty boundary (where a boundary-overfit smooth or a Firth switch on
266/// finite data would otherwise be accepted) with a prior worth a fixed small
267/// fraction of one observation. `8e-4` pseudo-observations reproduces the
268/// previously fixture-calibrated large-support floor `τ · ¼ = 2e-4` exactly at
269/// the calibration point, now expressed as an effective-prior-strength rather
270/// than a tuned λ value.
271///
272/// MEASURED 2026-07-31 (#2612). This value is not inert and it has never been
273/// measured against the quantity it controls. On the penguins real-data arm
274/// (stride-3, `n_train = 228`, minority class 46, so the wall is
275/// `8.0e-4 × 0.25 × 50/46 = 2.173913043e-4`), **four of the eight live
276/// null-space λ sit on this wall exactly** — `ratio_to_wall = 1.000000`. They
277/// are boundary solutions, not stationary points of the REML criterion: the box
278/// stopped them, and the box is this constant.
279///
280/// Everything downstream is therefore a readout of it. Sweeping ONLY this
281/// constant, same base commit, same split, exact conditioned quadrature:
282///
283/// ```text
284///   pseudo_obs   λ wall      posterior log-loss   plug-in log-loss   calib gap
285///   8.0e-4       2.174e-4    0.161820             0.024718           +0.1187
286///   8.0e-3       2.174e-3    0.069526             0.025097           +0.0401
287///   8.0e-2       2.174e-2    0.065275             0.035297           +0.0360
288/// ```
289///
290/// `calib gap` is `mean predicted probability on the argmax class − held-out
291/// accuracy` (accuracy is `0.982456` in every row; no reference tool involved).
292/// A 10× wall MORE THAN HALVES the published posterior log-loss while the mode
293/// barely moves, so the width is this constant's and the mode is not.
294///
295/// An exact subspace attribution — same fit, saved Laplace covariance zeroed
296/// outside one subspace at a time — says the width does not sit where the wall
297/// is applied. Cost in nats above the plug-in, shipped wall / 10× wall:
298///
299/// ```text
300///   intercept (2 coords, unpenalized)   0.071778  →  0.014949
301///   range space (64 coords, edf≈6e-4)   0.062538  →  0.034422
302///   null space (8 coords, ON the wall)  0.002564  →  0.000989
303/// ```
304///
305/// The railed coordinates carry 1.9% of the cost, yet tightening their prior
306/// tenfold cuts the UNPENALIZED intercept's marginal contribution 4.8×. The
307/// near-flat prior on the linear direction does not make that direction wide; it
308/// makes the joint Hessian near-singular, and the width surfaces on whatever is
309/// correlated with it. Attribution by marginal block therefore names the
310/// carrier, not the cause.
311///
312/// DO NOT TUNE THIS ON THAT CURVE. Choosing `8.0e-3` because it clears a
313/// held-out log-loss bar on one dataset is the objection #2615 raised against
314/// choosing `EFFECTIVE_DF_FLOOR_RELATIVE_FRACTION`, and the sweep above is
315/// diagnostic evidence, not a selection procedure. The measurement says the
316/// criterion has no interior optimum here — a quasi-separated multinomial's
317/// marginal likelihood keeps rewarding a flatter prior on the separating
318/// direction — so the repair is a prior that is derived for that regime (the
319/// Jeffreys/Firth path this same issue is already driving), not a wall chosen to
320/// stop the slide at a convenient place.
321const MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS: f64 = 8.0e-4;
322
323/// Reference class support `n_ref`: the effective sample size per class at which
324/// the data Fisher information `n_c · I₁` is large enough that the floor sits at
325/// its well-supported value. Below `n_ref` the per-class data information shrinks
326/// like `n_c`, so to keep the floor's prior from vanishing *relative to* that
327/// shrinking data the effective pseudo-observation count is scaled up by
328/// `n_ref / n_c` (the prior is held to a fixed fraction of the data information,
329/// not a fixed absolute λ). At `n_c = n_ref` the scale is exactly 1.
330const MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT: f64 = 50.0;
331
332/// Cap on the floor's prior strength in the very-sparse limit, in
333/// pseudo-observations. As `n_c → 0` the `n_ref / n_c` scaling diverges; the cap
334/// holds the prior at `4e-3` pseudo-observations (`τ_max · ¼ = 1e-3` at the
335/// calibration point, the previously-tuned strong-floor value) so the floor
336/// stays a proper prior rather than a hard constraint that would dominate the
337/// likelihood for a handful-of-rows class.
338const MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX: f64 = 4.0e-3;
339
340/// Continuous, Fisher-information-scaled lower λ floor for the formula path,
341/// derived from the minority class's effective sample size `n_c`.
342///
343/// # Derivation (effective-prior-strength / Fisher geometry)
344///
345/// The penalty `½ λ βᵀ S β` with `‖S‖_F = 1` adds curvature `λ` to the class
346/// logit direction; one observation adds at most `I₁ = ¼` there. So a floor that
347/// sets `λ_floor = τ_eff · I₁` gives the smooth a prior worth `τ_eff`
348/// pseudo-observations. We want a fixed *absolute* prior `τ` for a well-supported
349/// class, but for a minority class with only `n_c` effective observations the
350/// data information in its block is `n_c · I₁`; holding the prior to a fixed
351/// *fraction* of that shrinking data information requires
352///
353/// ```text
354///     τ_eff(n_c) = τ · max(1, n_ref / n_c),   clamped to [τ, τ_max]
355///     λ_floor(n_c) = τ_eff(n_c) · I₁
356/// ```
357///
358/// This is the *same* `base · max(1, c0/c)` envelope as before — but `base`,
359/// `sparse`, and `c0` are no longer fixture-tuned magic numbers: `base = τ·I₁`,
360/// `sparse = τ_max·I₁`, and `c0 = n_ref` are an effective-prior-strength of
361/// `τ`/`τ_max` pseudo-observations against the maximal per-observation softmax
362/// information `I₁ = ¼`. Properties preserved by construction:
363///   * reduces EXACTLY to `τ·I₁` for well-supported classes (`n_c ≥ n_ref`);
364///   * reduces EXACTLY to `τ_max·I₁` for very sparse classes
365///     (`n_c ≤ n_ref·τ/τ_max`, here `n_c ≤ 10`);
366///   * interpolates monotonically and continuously between them in the middle —
367///     no cliff at `n_c = n_ref`.
368/// At the calibration point the endpoints equal the previous `2e-4` / `1e-3`, so
369/// fixtures whose smallest class has `n_c ≥ 50` (penguins, the vgam softmax
370/// arms) are unaffected — they sit at `τ·I₁ = 2e-4` exactly as before.
371fn multinomial_formula_min_lambda(y_one_hot: ArrayView2<'_, f64>) -> f64 {
372    let base = MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
373    let sparse =
374        MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
375    let min_class_count = (0..y_one_hot.ncols())
376        .map(|class| y_one_hot.column(class).sum())
377        .fold(f64::INFINITY, f64::min);
378    if !min_class_count.is_finite() || min_class_count <= 0.0 {
379        return base;
380    }
381    // Effective pseudo-observation prior strength: held to a fixed fraction of
382    // the shrinking per-class data information once n_c falls below n_ref.
383    let pseudo_obs_scale =
384        (MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT / min_class_count).max(1.0);
385    (base * pseudo_obs_scale).clamp(base, sparse)
386}
387
388fn max_abs_eta_location(eta: ArrayView2<'_, f64>) -> (f64, usize, usize) {
389    let mut best = (0.0_f64, 0usize, 0usize);
390    for ((row, active_class), &value) in eta.indexed_iter() {
391        let abs = value.abs();
392        if abs > best.0 {
393            best = (abs, row, active_class);
394        }
395    }
396    best
397}
398
399/// Separation gate for the REML/LAML **formula** path.
400///
401/// Unlike the bare fixed-λ driver [`fit_penalized_multinomial`] (which has no
402/// outer REML state and so must reject a saturated, non-converged iterate as a
403/// separation artifact at the [`MULTINOMIAL_SEPARATION_ETA_THRESHOLD`] logit
404/// magnitude), the formula path can return a finite saturated mode after the
405/// coupled outer optimizer has selected smoothing parameters. A `|η| >= 25`
406/// gate is therefore wrong here: the penguins arm can legitimately have large
407/// fitted logits while still producing finite probabilities and a usable REML
408/// mode.
409///
410/// Only a genuinely NON-FINITE `η` (a NaN/Inf blow-up in the inner linear
411/// algebra) is a real formula-path failure. A finite, even saturated, `η` is
412/// accepted so the truth-recovery / match-or-beat bars are evaluated against the
413/// actual fitted surface instead of an adapter diagnostic.
414fn multinomial_formula_separation_diagnostic(
415    inner_cycles: usize,
416    outer_iterations: usize,
417    block_states: &[ParameterBlockState],
418) -> Option<EstimationError> {
419    let mut nonfinite: Option<(f64, usize, usize)> = None;
420    for (active_class, state) in block_states.iter().enumerate() {
421        for (row, &value) in state.eta.iter().enumerate() {
422            if !value.is_finite() {
423                nonfinite = Some((value, row, active_class));
424                break;
425            }
426        }
427        if nonfinite.is_some() {
428            break;
429        }
430    }
431    nonfinite.map(|(value, row_index, active_class_index)| {
432        EstimationError::MultinomialSeparationDetected {
433            iteration: inner_cycles.max(outer_iterations),
434            max_abs_eta: value.abs(),
435            active_class_index,
436            row_index,
437        }
438    })
439}
440
441/// Separation EVIDENCE gate for the conditional Firth/Jeffreys engagement on
442/// the formula REML path (#715 / #753).
443///
444/// The structural mathematics (#715 issue thread): for any coefficient
445/// direction `v` with `S v = 0` (a penalty-null direction — intercept, a
446/// smooth's polynomial null component, an unpenalized parametric term), the
447/// penalized joint Hessian satisfies `(H + S_λ) v = H v` for EVERY smoothing
448/// parameter ρ. When the data (quasi-)separate, the softmax Fisher weight
449/// `W = diag(p) − p pᵀ → 0` on the saturated rows, so `H v = JᵀWJ v → 0` along
450/// the penalty-null directions those rows support: `(H + S_λ) v ≈ 0` for every
451/// ρ — NO λ can repair it, the inner Newton can never certify a KKT point
452/// there, and every outer REML startup seed is rejected (the penguins
453/// real-data arm). The only principled cure is a PROPER prior on that
454/// quotient-null subspace — the Jeffreys/Firth term `Φ = ½ log|ZᵀHZ|`, whose
455/// Gauss–Newton curvature supplies the missing `O(1)` bound.
456///
457/// But the Firth prior is not free on interior data: unconditionally armed, it
458/// shrinks fitted class probabilities toward the uniform simplex `1/K`
459/// (an `O(1/n)` pull that the synthetic match-or-beat arm of #715 measured as
460/// a real truth-RMSE loss vs the unbiased criterion). So the formula path
461/// engages it ONLY on separation evidence, mirroring the #753 "diagnose, then
462/// arm" split:
463///
464/// * a NON-FINITE logit — the inner linear algebra blew up along an unbounded
465///   direction.
466///
467/// Returns `Some(description)` naming the witnessing logit when evidence is
468/// found, `None` for a finite fit (which is then accepted as-is, with zero
469/// Firth bias). A FAILED unbiased solve (`Err` from the rho-prior driver, e.g.
470/// "no startup seed passed") is the second evidence form and is handled
471/// directly at the call site in [`fit_penalized_multinomial_formula`].
472fn multinomial_formula_separation_evidence(block_states: &[ParameterBlockState]) -> Option<String> {
473    for (active_class, state) in block_states.iter().enumerate() {
474        for (row, &value) in state.eta.iter().enumerate() {
475            if !value.is_finite() {
476                return Some(format!(
477                    "non-finite logit eta[row {row}, active class {active_class}] = {value}"
478                ));
479            }
480        }
481    }
482    None
483}
484
485/// Certify (quasi-)separation at a converged multinomial mode from the curvature
486/// the fit ACTUALLY has — the exact Fisher information PLUS the selected joint
487/// penalty — on the fit's certified identifiable tangent span.
488///
489/// Finiteness is not an identification certificate: a tiny smoothing floor can
490/// keep every logit finite while the likelihood contributes less than one
491/// observation-equivalent of curvature along a separating direction. The
492/// Jeffreys objective already owns the canonical absolute/relative conditioning
493/// gate for precisely that geometry. Preparing its plan here makes the
494/// conditional-refit decision use the same reduced spectrum, smooth gate, and
495/// coefficient gauge as the term that will bound the refit. Passing the fit's
496/// saved gauge is essential: an aliased raw column is not separation evidence,
497/// because it is absent from the active statistical model.
498///
499/// # Why `H + S_λ` and not `H` (#2612)
500///
501/// The gate's absolute arm saturates at `λ_min < 1`, i.e. when the
502/// worst-determined direction holds less than **one observation-equivalent** of
503/// curvature, and its own derivation says why that is the right scale: such a
504/// direction "is, by construction, not identified by the data and is the regime
505/// Firth exists to stabilise". It also states the premise that makes it
506/// conservative — "it never fires on a genuinely well-conditioned large-`n` fit,
507/// whose `λ_min = O(n) ≫ 1`".
508///
509/// (The predicate this call site consults is `JointJeffreysPlan::is_active`,
510/// which is `gate_weight != 0`, and the weight is a C¹ ramp reaching exactly
511/// zero only at `CONDITIONING_GATE_ABSOLUTE_CLEAR = 16`. So the arming boundary
512/// is sixteen observation-equivalents, not one — measured: an `n = 3000, k = 5`
513/// fit at `λ_min = 1.989` still armed, at weight `≈ 0.988`. That ramp is a
514/// continuity device for an always-on term — a binary gate makes `Φ(ρ)` jump,
515/// which is the #787 regression — so it stays; what changes is which matrix it
516/// is asked about.)
517///
518/// That premise is false for **every penalized smooth basis**, and this call site
519/// used to hand the gate the bare Fisher information `H`. A `k`-dimensional
520/// spline basis has high-frequency directions the data barely resolve — that is
521/// the entire reason they are penalized — so `λ_min(H)` sits well below one
522/// observation-equivalent on ordinary, well-behaved, nowhere-separating data.
523/// Measured on labels DRAWN from a smooth softmax truth (`y ~ s(x1,k=6) +
524/// s(x2,k=6)`, `n = 600`, every class keeping appreciable probability
525/// everywhere):
526///
527/// ```text
528///   lambda_min = 4.051e-1   lambda_max = 1.575e2   ratio = 2.572e-3
529///   Jeffreys gate weight = 1   =>  "separation evidence"  =>  Firth/Jeffreys refit
530/// ```
531///
532/// The relative arm is nowhere near firing (`2.6e-3` against a `1e-6` clear
533/// knot); the verdict is the absolute arm reading a *penalized* direction as if
534/// nothing were holding it. So the "#715 arm ONLY on separation evidence" design
535/// was unconditional in practice on any multinomial GAM carrying a smooth.
536///
537/// The distinction the gate has to make is exactly the one #715 derives: a
538/// direction `v` is beyond `λ`'s reach only when `S v = 0`, because
539/// `(H + S_λ)v = Hv + λSv`. Where `Sv ≠ 0` the smoothing parameter supplies the
540/// missing curvature and the direction is identified — by the prior the model
541/// already has. Reading `H` alone cannot tell those apart; reading the penalized
542/// curvature `H + S_λ` at the selected `λ` is the same question asked of the
543/// objective that was actually optimized, and its units are unchanged (the
544/// Frobenius-normalized `S` makes `λS` directly comparable to data Fisher
545/// information — see [`MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS`]).
546///
547/// The unpenalized spectrum is reported alongside the deciding one whenever the
548/// certificate fires, because "the data do not determine this direction" and
549/// "and no `λ` repairs it" are two different statements and the verdict rests on
550/// the second. That costs one extra reduced eigendecomposition, paid only on the
551/// branch that arms.
552///
553/// # And the SUBSPACE, not only the verdict (#2612)
554///
555/// Reading `H + S_λ` fixed which matrix the verdict is taken on. It did not fix
556/// which directions the verdict is taken *over*, and that is the second half of
557/// the same sentence: `(H + S_λ)v = Hv + λSv`, so a direction is beyond every
558/// `λ`'s reach **exactly when `S_λ v = 0`**. Where `S_λ v ≠ 0` the model already
559/// carries a proper prior on `v`; the posterior there is proper, and
560/// [`crate::multinomial_predictive`] integrates it EXACTLY — the published
561/// probability is a ratio of normalising constants with a per-row measured error,
562/// not a Gaussian approximation — so a diffuse penalized direction already shows
563/// up in the published probability as the width it is. Arming a second, much
564/// stronger prior there is not stabilisation; it is a different model.
565///
566/// That distinction is not cosmetic, because the term this certificate arms does
567/// not act only on the direction that armed it. `jeffreys_antiderivative`
568/// saturates at `Λ = CONDITIONING_GATE_ABSOLUTE_CLEAR = 16` observation-
569/// equivalents: below `Λ` a direction earns the full `1/λ` prior push, above it
570/// essentially none. The full-span choice was justified by "the Jeffreys score is
571/// `O(1)` against the data's `O(n)` Fisher information, so on a data-identified
572/// direction its only effect is the `O(1/n)` Firth bias correction". **On a
573/// quasi-separated multinomial the data's information is not `O(n)` in ANY
574/// direction**: the softmax Fisher weight `W = diag(p) − p pᵀ` collapses to
575/// `≈ 0.005` per row on a confident fit, so measured at the penguins witness's
576/// certified unbiased mode,
577///
578/// ```text
579///   likelihood alone:  lambda_min = -8.8e-19   lambda_max =    1.4423
580///   H + S_lambda:      lambda_min =  1.92e-4   lambda_max = 2298.5
581/// ```
582///
583/// — `λ_max(H) = 1.44` over a **74-dimensional** span at `n = 228`, i.e. every
584/// direction of the basis sits inside the window where the prior acts at full
585/// strength, while the fit's own penalty already bounds most of them up to
586/// `2298`. The armed fit then publishes mean argmax probability `0.828` against
587/// held-out accuracy `0.965`: a 13.7-point calibration deficit, in the MODE
588/// (plug-in `0.20755` against posterior-mean `0.20917`), which is the
589/// under-confidence #2612 is named for.
590///
591/// So the certificate is taken on the subspace no `λ` reaches. With NO penalised
592/// component that subspace is the whole identifiable span and this is byte-for-
593/// byte the previous decision — which is why a quasi-separated design carrying no
594/// penalty still arms, and must. `ker(S_λ)` is measured by the same relative rule
595/// [`crate::multinomial_reml::measured_penalty_rank`] reports as a count, so the
596/// dimension and the basis can never disagree.
597///
598/// REJECTED: handing the Jeffreys TERM `H + S_λ` instead of `H` (the honest
599/// completion, and the right long-run answer) — the term's information would then
600/// depend explicitly on `ρ`, and the outer hypergradient carries no explicit-`ρ`
601/// channel for it, so the analytic gradient would silently stop matching its own
602/// value; that is a larger change than this issue can verify. Widening the gate's
603/// knots (they are derived, and they are not what is wrong). Reading the
604/// deficient subspace of `H + S_λ` itself (it moves with `β` and `ρ`, so the
605/// Jeffreys derivative tower would no longer be differentiating a fixed span).
606fn multinomial_formula_penalized_separation_evidence(
607    family: &MultinomialFamily,
608    specs: &[ParameterBlockSpec],
609    block_states: &[ParameterBlockState],
610    identifiable_span: ArrayView2<'_, f64>,
611    joint_specs: &[gam_problem::JointPenaltySpec],
612    n_penalty_components: usize,
613    joint_log_lambdas: Option<&Array1<f64>>,
614) -> Result<Option<MultinomialSeparationCertificate>, String> {
615    if let Some(evidence) = multinomial_formula_separation_evidence(block_states) {
616        // A saturated or non-finite mode: there is no certified curvature to
617        // measure a span from, so the term keeps its derived `ker(S_lambda)`
618        // span and only the verdict travels.
619        return Ok(Some(MultinomialSeparationCertificate {
620            evidence,
621            measured_span: None,
622        }));
623    }
624    let information = family
625        .joint_jeffreys_information_with_specs(block_states, specs)?
626        .ok_or_else(|| {
627            "multinomial separation certificate requires exact joint Fisher information".to_string()
628        })?;
629    let coefficient_dim = information.nrows();
630    if information.ncols() != coefficient_dim {
631        return Err(format!(
632            "multinomial separation certificate received non-square Fisher information {}x{}",
633            information.nrows(),
634            information.ncols()
635        ));
636    }
637    // The curvature the certified mode actually sits in. `multinomial_joint_
638    // penalty_operator` is the fit's ONE assembly of `S_λ` — the same matrix the
639    // influence reconstruction and the published payload read — so the arming
640    // decision and the published penalty cannot describe different priors.
641    let s_lambda = multinomial_joint_penalty_operator(
642        joint_specs,
643        joint_log_lambdas,
644        n_penalty_components,
645        coefficient_dim,
646    )
647    .map_err(|error| {
648        format!(
649            "multinomial separation certificate could not assemble the selected joint \
650             penalty: {error}"
651        )
652    })?;
653    let penalized = &information + &s_lambda;
654    // The subspace no `λ` reaches. `S_λ` is PSD, so `ker(S_λ)` is exactly the
655    // eigenvectors at (relative) zero, classified by the same rule
656    // `measured_penalty_rank` reports as a count. With no penalised component
657    // `S_λ` is the zero operator and this is the whole identifiable span, which
658    // is what makes the unpenalised quasi-separated design's verdict unchanged.
659    let reduced_penalty = identifiable_span.t().dot(&s_lambda.dot(&identifiable_span));
660    let unreached =
661        crate::multinomial_reml::measured_penalty_nullspace(&reduced_penalty).map_err(|error| {
662            format!("multinomial separation certificate could not measure ker(S_lambda): {error}")
663        })?;
664    log::info!(
665        "multinomial separation certificate: {}/{} identifiable direction(s) are unreached by \
666         any smoothing parameter (S_lambda v = 0)",
667        unreached.ncols(),
668        reduced_penalty.nrows(),
669    );
670    // Every direction is penalised: the model's own prior is proper everywhere,
671    // and whatever width is left belongs to the posterior the predictive
672    // integrates exactly.
673    if unreached.ncols() == 0 {
674        return Ok(None);
675    }
676    let unreached_span = identifiable_span.dot(&unreached);
677    let plan = gam_solve::estimate::reml::jeffreys_subspace::JointJeffreysPlan::prepare(
678        penalized.view(),
679        unreached_span.view(),
680    )?;
681    // The DECISION, not the contribution: `is_under_identified` is the gate's
682    // derived predicate at one observation-equivalent, while `is_active` is
683    // `weight != 0` and therefore boundaries on the ramp's far knot at sixteen.
684    // Measured: the synthetic softmax-drawn fixture lands at
685    // `λ_min(H + S_λ) = 5.508` — five and a half observations' worth of
686    // curvature in the worst direction, comfortably identified by the
687    // constant's own derivation — and `is_active` still says yes, at weight
688    // `0.783`, because the ramp has not finished tapering. Choosing an
689    // estimand on the support of a smoothing device is the same category error
690    // as choosing one on a cost cap.
691    let (unreached_min, unreached_max) = plan.information_extrema();
692    log::info!(
693        "multinomial separation certificate: on the unreached subspace H+S_lambda lies in \
694         [{unreached_min:e}, {unreached_max:e}], gate weight {:e}, under_identified={}, \
695         singular={}",
696        plan.conditioning_gate_weight(),
697        plan.is_under_identified(),
698        plan.reduced_information_is_singular(),
699    );
700    // UNDER-IDENTIFIED, not merely singular (#2612). "Improper" is the
701    // mathematically minimal reason to add a prior, and it was measured here and
702    // rejected: with the certificate keyed on singularity instead, penguins
703    // disarms and produces a beautiful MODE — held-out plug-in log-loss 0.02481
704    // against nnet::multinom's 0.09494, calibration gap +0.010, in 4.0 s — and
705    // then cannot publish a probability at all, because
706    // `predict_multinomial_formula` is the posterior MEAN and that posterior is
707    // not describable:
708    //
709    // ```text
710    //   penguins stride-3: row 24 predictive mass 0.944  (defect 5.6e-2)
711    //   penguins stride-4: row 20 predictive mass 0.517  (defect 4.8e-1)
712    //   banded n_train=360, 720: augmented mode did not converge in 100 Newton
713    //                            iterations
714    // ```
715    //
716    // A posterior that is proper only because a direction carries 2.9e-3
717    // observation-equivalents is proper in name: the mode exists, and nothing
718    // else about it does. The mass-defect identity refuses rather than lying,
719    // which is the right behaviour and also the proof — the estimand this path
720    // publishes cannot be computed there. So the criterion stays the gate's own
721    // derived one, at ONE observation-equivalent, and what #2612 changes is
722    // WHERE it is asked (the directions no lambda reaches) and where the term it
723    // arms is allowed to act (the same subspace).
724    if !plan.is_under_identified() {
725        return Ok(None);
726    }
727    let (lambda_min, lambda_max) = plan.information_extrema();
728    let relative = if lambda_max > 0.0 {
729        lambda_min / lambda_max
730    } else {
731        f64::NEG_INFINITY
732    };
733    // Both wider readings, so the refusal shows what it did NOT decide on: the
734    // whole penalized span (which the model's own prior bounds) and the
735    // likelihood alone (which is not the curvature the fit has).
736    let whole_penalized = gam_solve::estimate::reml::jeffreys_subspace::JointJeffreysPlan::prepare(
737        penalized.view(),
738        identifiable_span,
739    )?;
740    let (span_min, span_max) = whole_penalized.information_extrema();
741    let unpenalized = gam_solve::estimate::reml::jeffreys_subspace::JointJeffreysPlan::prepare(
742        information.view(),
743        identifiable_span,
744    )?;
745    let (data_min, data_max) = unpenalized.information_extrema();
746    // ── The verdict has fired. WHERE the prior belongs is a second question ──
747    //
748    // The two are not the same question and this lane measured what happens when
749    // one object answers both.
750    //
751    // **The verdict** — *is there evidence this model needs a proper prior?* — is
752    // a statement about the model's STRUCTURE: a direction no smoothing parameter
753    // can ever reach, which the data does not determine either. That is
754    // `ker(S_λ)` and the gate's predicate on it, exactly as above, and it is what
755    // keeps a fit on non-separating data byte-unchanged (arm 1) and a genuinely
756    // separated design armed (arm 2).
757    //
758    // **The span** — *where does that prior belong?* — is a statement about the
759    // fit's ARITHMETIC at the smoothing it selected. Measured at the penguins
760    // stride-4 unbiased mode:
761    //
762    // ```text
763    //   ker(S_lambda):            2 of 74 directions, lambda_min(H+S_lambda) = 1.9e-3
764    //   whole identifiable span:                      lambda_min(H+S_lambda) = 5.1e-5
765    // ```
766    //
767    // The worst-bounded direction — five orders below one observation-equivalent
768    // — is NOT in the kernel. It is a `range(S)` direction whose selected λ railed
769    // at `MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS = 8e-4` pseudo-observations, so the
770    // claim that backs the kernel ("on `range(S)` the model already carries a
771    // proper prior") is true in name and false in magnitude. Left unarmed the
772    // coefficient runs to `|η|∞ ≈ 45` and the posterior-mean predictive refuses to
773    // publish. Arming the measured set instead takes the same fixture to
774    // `acc = 0.9767`, `log-loss 0.07420` and a publishable posterior.
775    //
776    // Trying to make ONE object answer both was measured and rejected: at every
777    // scale of the metric below, either `genuine_separation_still_arms_the_prior`
778    // disarmed (the verdict got stricter) or
779    // `a_quasi_separated_smooth_fit_is_calibrated` broke at `-0.0525` against a
780    // `0.05` bar (the span got wider). Threading that needle by choosing the
781    // scale is choosing an estimand on a curve, which is the objection #2615
782    // raised against choosing `EFFECTIVE_DF_FLOOR_RELATIVE_FRACTION`.
783    //
784    // The span is measured in the CLR METRIC, not in the raw ALR coordinates.
785    // Relabelling classes acts on θ by a non-orthogonal contrast change, so a
786    // threshold on the raw spectrum selects a different PHYSICAL subspace for each
787    // choice of reference class; a kernel is congruence-invariant and never had
788    // that problem. Measured cost of taking it raw, on
789    // `multinomial_fit_is_invariant_to_reference_class_1587`: predicted-probability
790    // drift `4.093e-3` across three labelings of one dataset against a `1e-3` bar,
791    // with refit noise exactly `0`.
792    let coefficient_metric = crate::multinomial_reml::centered_class_coefficient_metric(
793        family.active_classes(),
794        family.total_classes,
795        family.design.ncols(),
796    );
797    let reduced_penalized = identifiable_span
798        .t()
799        .dot(&penalized.dot(&identifiable_span));
800    let reduced_metric = identifiable_span
801        .t()
802        .dot(&coefficient_metric.dot(&identifiable_span));
803    let measured =
804        crate::multinomial_reml::under_identified_subspace(&reduced_penalized, &reduced_metric)
805            .map_err(|error| {
806                format!(
807                    "multinomial separation certificate could not measure the under-identified \
808                     subspace of H+S_lambda: {error}"
809                )
810            })?;
811    log::info!(
812        "multinomial separation certificate: the armed term acts on {}/{} identifiable \
813         direction(s) holding under one observation-equivalent of curvature in H+S_lambda at \
814         the certified mode, of which {} are unreached by any smoothing parameter",
815        measured.ncols(),
816        reduced_penalized.nrows(),
817        unreached.ncols(),
818    );
819    // A measured span that came out EMPTY leaves the derived `ker(S_λ)` route in
820    // place rather than disarming a term the verdict just armed: the verdict is
821    // the decision, and a span this measurement cannot produce is a reason to
822    // fall back, not to overrule it.
823    let measured_span = if measured.ncols() == 0 {
824        None
825    } else {
826        Some(std::sync::Arc::new(identifiable_span.dot(&measured)))
827    };
828    Ok(Some(MultinomialSeparationCertificate {
829        evidence: format!(
830            "no smoothing parameter reaches {}/{} identifiable direction(s), and on that \
831             subspace the penalized curvature H+S_lambda is under-identified at the certified \
832             mode: lambda_min={lambda_min:e}, lambda_max={lambda_max:e}, \
833             lambda_min/lambda_max={relative:e}, Jeffreys gate weight={:e}; the armed term acts \
834             on the {} direction(s) the model fails to bound at the selected lambda \
835             (whole identifiable span: H+S_lambda in [{span_min:e}, {span_max:e}], likelihood \
836             alone in [{data_min:e}, {data_max:e}])",
837            unreached.ncols(),
838            reduced_penalty.nrows(),
839            plan.conditioning_gate_weight(),
840            measured.ncols(),
841        ),
842        measured_span,
843    }))
844}
845
846/// The separation certificate's two answers (#2612): whether the Jeffreys/Firth
847/// prior is armed, and — separately — where it acts.
848#[derive(Debug, Clone)]
849pub(crate) struct MultinomialSeparationCertificate {
850    /// Human-readable evidence, carried to the payload as
851    /// `MultinomialSavedModel::separation_evidence`.
852    pub(crate) evidence: String,
853    /// Orthonormal basis, in raw joint coefficient order, of the directions the
854    /// model fails to bound at the smoothing it selected — the span the armed
855    /// term acts on. `None` when there is none to measure (a saturated or
856    /// non-finite solve) or when the measurement came out empty, in which case
857    /// the term keeps its derived `ker(S_lambda)` span.
858    pub(crate) measured_span: Option<std::sync::Arc<Array2<f64>>>,
859}
860
861impl MultinomialSeparationCertificate {
862    pub(crate) fn as_str(&self) -> &str {
863        &self.evidence
864    }
865}
866
867impl std::fmt::Display for MultinomialSeparationCertificate {
868    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
869        f.write_str(self.as_str())
870    }
871}
872
873/// Inputs to [`fit_penalized_multinomial`].
874///
875/// The penalty matrix `S` is shared across classes; per-class smoothing
876/// parameters `lambdas` (length `K - 1`) scale `S` independently for each
877/// active class. The full block-replicated penalty is `diag_a(λ_a) ⊗ S`,
878/// which is exactly what [`gam_solve::arrow_schur::KroneckerPenaltyOp`]
879/// expresses in matrix-free form when this driver is later lifted into the
880/// arrow-Schur loop.
881#[derive(Debug, Clone)]
882pub struct MultinomialFitInputs<'a> {
883    /// Design matrix `X ∈ ℝ^{N×P}` (one row per observation).
884    pub design: ArrayView2<'a, f64>,
885    /// Categorical response `Y ∈ ℝ^{N×K}`. Each row must be a point on the
886    /// probability simplex (`y_c ≥ 0`, `Σ_c y_c = 1`): a one-hot indicator for
887    /// hard classification, or a label-smoothed probability vector. Rows whose
888    /// mass departs from 1 are rejected — the softmax residual gradient and
889    /// Fisher block are the derivatives of `Σ_c y_c log p_c` only under the
890    /// simplex constraint (see `validate_multinomial_simplex`).
891    pub y_one_hot: ArrayView2<'a, f64>,
892    /// Shared smoothing penalty `S ∈ ℝ^{P×P}` (symmetric, PSD).
893    pub penalty: ArrayView2<'a, f64>,
894    /// Per-active-class smoothing parameter `λ_a` (length `K - 1`).
895    pub lambdas: ArrayView1<'a, f64>,
896    /// Optional per-row weights (length `N`); `None` ⇒ uniform 1.0.
897    pub row_weights: Option<ArrayView1<'a, f64>>,
898    /// Optional per-row Fisher-block override, shape `(N, K-1, K-1)` in the
899    /// active-class gauge (the reference class `K-1` is dropped). When `Some`,
900    /// each Newton step uses this block as the curvature `W` in place of the
901    /// analytic softmax Fisher `w_n (δ_ab p_a − p_a p_b)`; the gradient/residual
902    /// path stays analytic, so this is a curvature-only override (the
903    /// research escape-hatch for latent multinomial fits, issue #349). Each
904    /// per-row block must be symmetric, PSD, and finite — preconditions the
905    /// FFI boundary discharges before constructing this view.
906    pub fisher_w_override: Option<ArrayView3<'a, f64>>,
907    /// Maximum Newton iterations; recommend 50.
908    pub max_iter: usize,
909    /// Relative-step convergence tolerance; recommend 1e-7.
910    pub tol: f64,
911    /// Optional checkpoint emitted by a prior fixed-λ multinomial stall on
912    /// the same design, response, weights, offsets, penalty, and lambdas. A
913    /// `MultinomialNewton` checkpoint resumes the ordinary softmax objective;
914    /// a `MultinomialFirth` checkpoint resumes the Jeffreys/Firth separation
915    /// objective directly. Any other stage or coefficient shape is rejected.
916    pub resume_from: Option<&'a FixedLambdaCheckpoint>,
917}
918
919/// Outputs of [`fit_penalized_multinomial`].
920#[derive(Debug, Clone)]
921pub struct MultinomialFitOutputs {
922    /// Active-class coefficient block, shape `(P, K-1)` (column `a` is `β_a`).
923    /// The reference class `K - 1` has `β_{K-1} ≡ 0` by construction and is
924    /// not stored.
925    pub coefficients_active: Array2<f64>,
926    /// Fitted probabilities, shape `(N, K)`.
927    pub fitted_probabilities: Array2<f64>,
928    /// Number of Newton iterations executed (including the final step that
929    /// satisfied the tolerance). Non-convergence (outside the separation lane,
930    /// which escalates to the Firth refit) is surfaced as the typed
931    /// [`EstimationError::FixedLambdaNewtonDidNotConverge`] rather than an `Ok`
932    /// with a flag, so every constructed value of this struct is a certified
933    /// converged fit (SPEC: a fit only ever comes from a converged
934    /// optimization).
935    pub iterations: usize,
936    /// Penalized negative log-likelihood at the returned `β̂`:
937    /// `−log L(β̂) + ½ Σ_a λ_a · β̂_a^T S β̂_a`.
938    pub penalized_neg_log_likelihood: f64,
939    /// Unpenalized deviance `−2 log L(β̂)` for diagnostic reporting.
940    pub deviance: f64,
941    /// Joint Laplace posterior coefficient covariance `H⁻¹` at the converged
942    /// `β̂`, shape `(P·(K−1))×(P·(K−1))` (#1101). Block-ordered to match the
943    /// stacked active-class coefficient vector `β = [β_0; …; β_{K-2}]`: active
944    /// class `a`'s `P` coefficients occupy rows/cols `a·P .. (a+1)·P`, indexed
945    /// `θ[a·P + i] = β̂[i, a]`. This is the Laplace covariance from the factored
946    /// penalized Hessian `XᵀWX + diag_a(λ_a)⊗S`; it drives the delta-method
947    /// per-class probability standard errors
948    /// ([`Self::logistic_normal_softmax_moments`])
949    /// on the fixed-λ inner-solve path.
950    pub coefficient_covariance: Array2<f64>,
951}
952
953impl MultinomialFitOutputs {
954    /// Number of active classes `M = K − 1` (columns of
955    /// [`Self::coefficients_active`]).
956    pub fn n_active_classes(&self) -> usize {
957        self.coefficients_active.ncols()
958    }
959
960    /// Per-class coefficient dimension `P` (rows of
961    /// [`Self::coefficients_active`]).
962    pub fn p_per_class(&self) -> usize {
963        self.coefficients_active.nrows()
964    }
965
966    /// Integrate the logistic-normal coefficient posterior at fresh design rows:
967    /// `E[softmax(η)]` and its marginal standard deviations with
968    /// `η ~ N(x'β̂, x'Σx)`, the full joint covariance (cross-class blocks
969    /// included) contracted into each row's active-logit covariance before
970    /// deterministic adaptive integration.
971    ///
972    /// # This is NOT the posterior mean, and the name says so on purpose (#2612)
973    ///
974    /// The quantity a caller usually wants — the posterior mean probability —
975    /// is not this. Integrating a nonlinear functional over the Laplace Gaussian
976    /// keeps the curvature half of the `O(n⁻¹)` correction to a posterior mean
977    /// and drops the skewness half; on a (quasi-)separated softmax the two are
978    /// neither small nor same-signed, and the result is under-confident by up to
979    /// tens of percentage points at unchanged argmax. The posterior mean is
980    /// computed by [`crate::multinomial_predictive`] as a ratio of normalising
981    /// constants, and [`MultinomialSavedModel::predict_probabilities`] is the
982    /// entry point that publishes it.
983    ///
984    /// What this function IS, and why it stays: the exact moments of `softmax`
985    /// under a STATED Gaussian. That is a well-defined object with its own uses
986    /// (propagating a declared coefficient uncertainty through the link, and
987    /// pinning the integrator itself), and naming it for the Gaussian rather
988    /// than for the posterior is what keeps the two from being confused again.
989    pub fn logistic_normal_softmax_moments(
990        &self,
991        x_new: ArrayView2<'_, f64>,
992    ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
993        self.logistic_normal_softmax_moments_with_control(
994            x_new,
995            &MultinomialPosteriorIntegrationControl::default(),
996        )
997    }
998
999    pub fn logistic_normal_softmax_moments_with_control(
1000        &self,
1001        x_new: ArrayView2<'_, f64>,
1002        control: &MultinomialPosteriorIntegrationControl,
1003    ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
1004        let moments = integrate_multinomial_design_moments(
1005            self.coefficients_active.view(),
1006            self.coefficient_covariance.view(),
1007            x_new,
1008            control,
1009        )?;
1010        Ok((moments.class_mean, moments.class_standard_deviation))
1011    }
1012}
1013
1014#[derive(Clone, Copy)]
1015struct FirthResume<'a> {
1016    coefficients: ArrayView2<'a, f64>,
1017    completed_iterations: usize,
1018}
1019
1020fn fixed_lambda_checkpoint_coefficients(
1021    checkpoint: &FixedLambdaCheckpoint,
1022    expected_stage: FixedLambdaSolverStage,
1023    p: usize,
1024    m: usize,
1025) -> Result<Array2<f64>, EstimationError> {
1026    checkpoint.validate().map_err(|reason| {
1027        EstimationError::InvalidInput(format!(
1028            "multinomial fixed-λ resume checkpoint is invalid: {reason}"
1029        ))
1030    })?;
1031    if checkpoint.stage() != expected_stage {
1032        crate::bail_invalid_estim!(
1033            "multinomial fixed-λ resume checkpoint stage is {}, expected {}",
1034            checkpoint.stage(),
1035            expected_stage,
1036        );
1037    }
1038    if checkpoint.rows() != p || checkpoint.cols() != m {
1039        crate::bail_invalid_estim!(
1040            "multinomial fixed-λ resume checkpoint shape {}x{} does not match P x (K-1) = {p}x{m}",
1041            checkpoint.rows(),
1042            checkpoint.cols(),
1043        );
1044    }
1045    Array2::from_shape_vec((p, m), checkpoint.values().to_vec()).map_err(|error| {
1046        EstimationError::InvalidInput(format!(
1047            "multinomial fixed-λ resume checkpoint could not be reshaped: {error}"
1048        ))
1049    })
1050}
1051
1052/// Fit a penalized multinomial-logit GAM at fixed `λ`.
1053///
1054/// See the module docs for the optimization problem and conventions. This
1055/// function is the canonical inner solve: the outer REML/LAML loop, when
1056/// added, calls this at each `ρ = log λ` trial.
1057pub fn fit_penalized_multinomial(
1058    inputs: MultinomialFitInputs<'_>,
1059) -> Result<MultinomialFitOutputs, EstimationError> {
1060    let MultinomialFitInputs {
1061        design,
1062        y_one_hot,
1063        penalty,
1064        lambdas,
1065        row_weights,
1066        fisher_w_override,
1067        max_iter,
1068        tol,
1069        resume_from,
1070    } = inputs;
1071
1072    // ──────────────────────── family-specific validation ───────────────────
1073    // The shared engine re-validates the geometry common to every vector-GLM
1074    // (nonempty design, penalty shape, λ finiteness/non-negativity, override
1075    // `(N, M, M)` shape, finite design). The multinomial family owns the
1076    // class-count contract (`K ≥ 2`, λ length `K`), the per-row simplex
1077    // precondition under which the softmax residual/Fisher are the exact
1078    // derivatives of `Σ_c y_c log p_c`, and the row-weight check the likelihood
1079    // adapter consumes.
1080    let n_obs = design.nrows();
1081    let (y_rows, k) = y_one_hot.dim();
1082    if y_rows != n_obs {
1083        crate::bail_invalid_estim!(
1084            "fit_penalized_multinomial: y rows {y_rows} ≠ design rows {n_obs}"
1085        );
1086    }
1087    if k < 2 {
1088        crate::bail_invalid_estim!(
1089            "fit_penalized_multinomial: need at least 2 classes (got K={k})"
1090        );
1091    }
1092    let m = k - 1;
1093    // #2344: the fixed-λ contract is K per-CLASS lambdas (reference class
1094    // included), matching the permutation-equivariant carrier the REML route
1095    // selects (1326d0794). K−1 per-CONTRAST lambdas anchored the smoothing to
1096    // the arbitrary ALR baseline — relabeling the classes changed the fitted
1097    // model. No backcompat shim: K lambdas is the honest contract for nominal
1098    // classes.
1099    if lambdas.len() != k {
1100        crate::bail_invalid_estim!(
1101            "fit_penalized_multinomial: lambdas length {} ≠ K = {k} (one λ per class, \
1102             reference class included — the permutation-equivariant per-class contract, #2344)",
1103            lambdas.len()
1104        );
1105    }
1106    if let Some(fw) = fisher_w_override.as_ref() {
1107        if fw.dim() != (n_obs, m, m) {
1108            crate::bail_invalid_estim!(
1109                "fit_penalized_multinomial: fisher_w_override shape {:?} ≠ (N, K-1, K-1) = ({n_obs}, {m}, {m})",
1110                fw.dim()
1111            );
1112        }
1113    }
1114    if let Some(w) = row_weights.as_ref() {
1115        if w.len() != n_obs {
1116            crate::bail_invalid_estim!(
1117                "fit_penalized_multinomial: row_weights length {} ≠ N = {n_obs}",
1118                w.len()
1119            );
1120        }
1121        for (i, &v) in w.iter().enumerate() {
1122            if !(v.is_finite() && v >= 0.0) {
1123                crate::bail_invalid_estim!(
1124                    "fit_penalized_multinomial: row_weights[{i}] must be finite and ≥ 0 (got {v})"
1125                );
1126            }
1127        }
1128    }
1129    validate_multinomial_simplex(y_one_hot, "fit_penalized_multinomial")?;
1130
1131    let p = design.ncols();
1132    let resumed_newton_coefficients = match resume_from {
1133        Some(checkpoint) if checkpoint.stage() == FixedLambdaSolverStage::MultinomialFirth => {
1134            let coefficients = fixed_lambda_checkpoint_coefficients(
1135                checkpoint,
1136                FixedLambdaSolverStage::MultinomialFirth,
1137                p,
1138                m,
1139            )?;
1140            return fit_penalized_multinomial_firth_fallback(
1141                design,
1142                y_one_hot,
1143                penalty,
1144                lambdas,
1145                row_weights,
1146                max_iter,
1147                tol,
1148                Some(FirthResume {
1149                    coefficients: coefficients.view(),
1150                    completed_iterations: checkpoint.completed_iterations(),
1151                }),
1152            );
1153        }
1154        Some(checkpoint) => Some(fixed_lambda_checkpoint_coefficients(
1155            checkpoint,
1156            FixedLambdaSolverStage::MultinomialNewton,
1157            p,
1158            m,
1159        )?),
1160        None => None,
1161    };
1162    let vector_resume = resumed_newton_coefficients
1163        .as_ref()
1164        .map(|coefficients| VectorGlmResume {
1165            coefficients: coefficients.view(),
1166            completed_iterations: resume_from
1167                .map(FixedLambdaCheckpoint::completed_iterations)
1168                .unwrap_or(0),
1169        });
1170
1171    // ────────────────────────── likelihood construction ───────────────────
1172    let mut likelihood = MultinomialLogitLikelihood::with_classes(k)?;
1173    if let Some(w) = row_weights.as_ref() {
1174        likelihood = likelihood.with_row_weights(w.to_owned())?;
1175    }
1176
1177    // ─────────────────── shared penalized vector-GLM solve ─────────────────
1178    // The softmax Fisher block is dense across the `M = K − 1` active classes;
1179    // the engine assembles the coupled `(P·M)×(P·M)` penalized Hessian, runs
1180    // the damped Newton loop, and returns the converged `β̂` and `η = X β̂`.
1181    let solve = fit_penalized_vector_glm(
1182        PenalizedVectorGlmInputs {
1183            design,
1184            y: y_one_hot,
1185            penalty,
1186            lambdas,
1187            fisher_w_override,
1188            max_iter,
1189            tol,
1190            // #2344: the permutation-equivariant per-class metric — the fixed-λ
1191            // twin of the REML equivariant carrier (1326d0794). K per-class
1192            // lambdas on the centered class functions; reference-free by
1193            // construction, collapsing to the shared Centered metric at equal λ.
1194            class_penalty_metric:
1195                crate::penalized_vector_glm::ClassPenaltyMetric::EquivariantPerClass,
1196            resume_from: vector_resume,
1197        },
1198        &likelihood,
1199        "fit_penalized_multinomial",
1200    )?;
1201
1202    let fit = match solve {
1203        VectorGlmSolve::Converged(fit) => fit,
1204        VectorGlmSolve::Stalled(stall) => {
1205            return handle_multinomial_fixed_lambda_stall(
1206                stall,
1207                design,
1208                y_one_hot,
1209                penalty,
1210                lambdas,
1211                row_weights,
1212                max_iter,
1213                tol,
1214            );
1215        }
1216    };
1217
1218    let fitted_probabilities = likelihood.probabilities(fit.eta.view());
1219
1220    Ok(MultinomialFitOutputs {
1221        coefficients_active: fit.coefficients,
1222        fitted_probabilities,
1223        iterations: fit.iterations,
1224        penalized_neg_log_likelihood: -fit.log_likelihood + fit.penalty_term,
1225        deviance: -2.0 * fit.log_likelihood,
1226        coefficient_covariance: fit.coefficient_covariance,
1227    })
1228}
1229
1230/// Resolve a budget-exhausted fixed-λ softmax Newton solve: either the
1231/// separation lane (escalate to the Firth/Jeffreys proper-prior refit) or the
1232/// typed non-convergence error. Never mints a fit from the stalled iterate.
1233fn handle_multinomial_fixed_lambda_stall(
1234    stall: crate::penalized_vector_glm::VectorGlmStall,
1235    design: ArrayView2<'_, f64>,
1236    y_one_hot: ArrayView2<'_, f64>,
1237    penalty: ArrayView2<'_, f64>,
1238    lambdas: ArrayView1<'_, f64>,
1239    row_weights: Option<ArrayView1<'_, f64>>,
1240    max_iter: usize,
1241    tol: f64,
1242) -> Result<MultinomialFitOutputs, EstimationError> {
1243    let (max_abs_eta, row_index, active_class_index) = max_abs_eta_location(stall.eta.view());
1244    if max_abs_eta >= MULTINOMIAL_SEPARATION_ETA_THRESHOLD {
1245        // Perfect / quasi-perfect separation (#1854): the UNBIASED softmax MLE is
1246        // not finite along `active_class_index`'s saturated logit direction, so
1247        // the fixed-λ Newton above ran away (`|η| ≥ 25`, no convergence). A
1248        // penalty-null direction `v` (`S v = 0`, e.g. an unpenalized intercept /
1249        // linear-covariate column) under softmax saturation has
1250        // `(XᵀWX + λS) v → 0` for EVERY λ, so no smoothing parameter can bound it
1251        // — only a proper prior on that quotient-null subspace can. Rather than
1252        // hard-erroring, engage the Firth/Jeffreys proper prior automatically
1253        // (magic-by-default): the full-span `½ log|I(β)|` correction supplies the
1254        // `O(1)` curvature that keeps the estimate finite on exactly those
1255        // separated directions while leaving well-identified fits untouched. This
1256        // reuses the same coupled joint-Newton Jeffreys machinery the formula
1257        // REML path arms on separation evidence (see
1258        // `fit_penalized_multinomial_formula`), only here at the caller's fixed λ.
1259        // Engage the fallback, but never let an internal consistency panic in
1260        // the coupled joint-Newton assembly (e.g. the #1395 logdet-collapse
1261        // guard) escape as a process abort: convert any panic into the
1262        // documented hard separation diagnostic, exactly as if the refit had
1263        // returned Err. This mirrors the catch_unwind panic-to-typed-error
1264        // boundary already used around the faer / cudarc entry points, and keeps
1265        // the separation path no worse than the pre-#1854 clean error while the
1266        // Firth refit is still being hardened.
1267        // Start the Firth refit from the well-conditioned origin (β = 0), NOT
1268        // from the stalled Newton iterate. That stalled iterate is the runaway
1269        // separated point (`|η| ≥ 25`), where the softmax Fisher information
1270        // `I(β)` is numerically singular (every fitted probability is pinned to
1271        // the {0,1} simplex boundary, so `I → 0`). Warm-starting the Firth
1272        // Newton there is catastrophic: the first step `(I + λS)⁻¹ U*` is
1273        // unbounded and every backtracked candidate stays on the boundary, so
1274        // the line search exhausts without an accepted step and the refit stalls
1275        // at iteration 1 — it can never climb back to the interior Firth mode.
1276        // The Firth objective's interior mode is start-independent (the
1277        // `firth_solver_rejects_a_truncated_iterate` resume contract asserts the
1278        // same mode is reached from any interior start), and from `β = 0` the
1279        // information is well-conditioned, so a plain from-zero refit converges
1280        // reliably on exactly the separated data that defeated the fixed-λ
1281        // Newton above.
1282        let firth = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1283            fit_penalized_multinomial_firth_fallback(
1284                design,
1285                y_one_hot,
1286                penalty,
1287                lambdas,
1288                row_weights,
1289                max_iter,
1290                tol,
1291                None,
1292            )
1293        }));
1294        match firth {
1295            // SPEC: a fit object must only ever come from a converged
1296            // optimization — the Firth fallback itself surfaces a
1297            // budget-exhausted refit as the typed
1298            // `FixedLambdaNewtonDidNotConverge`, which is forwarded verbatim so
1299            // the caller sees which lane stalled and its evidence.
1300            Ok(Ok(out)) => return Ok(out),
1301            Ok(Err(err @ EstimationError::FixedLambdaNewtonDidNotConverge { .. })) => {
1302                return Err(err);
1303            }
1304            // Firth refit errored, or an internal consistency guard panicked:
1305            // fall back to the explicit hard separation diagnostic.
1306            Ok(Err(_)) | Err(_) => {
1307                return Err(EstimationError::MultinomialSeparationDetected {
1308                    iteration: stall.iterations,
1309                    max_abs_eta,
1310                    active_class_index,
1311                    row_index,
1312                });
1313            }
1314        }
1315    }
1316
1317    // SPEC: a fit object must only ever come from a converged optimization.
1318    // A stall WITHOUT the separation fingerprint (|η| below the threshold —
1319    // e.g. ill-conditioned data exhausting `max_iter`) is a typed error
1320    // carrying its evidence, never an Ok(outputs) with a flag.
1321    Err(stall.into_nonconvergence_error(
1322        FixedLambdaSolverStage::MultinomialNewton,
1323        "fit_penalized_multinomial (fixed-λ softmax damped Newton)",
1324    )?)
1325}
1326
1327/// Firth/Jeffreys-penalized multinomial refit engaged automatically when the
1328/// unbiased softmax MLE separates (#1854).
1329///
1330/// The unbiased fixed-λ solve ([`fit_penalized_multinomial`]) runs away on
1331/// (quasi-)separated data because the softmax likelihood has no finite mode along
1332/// the saturated logit direction and the smoothing penalty `S` cannot bound a
1333/// penalty-null direction (`S v = 0` ⇒ `(XᵀWX + λS) v → 0` for every λ). This
1334/// refit arms the full-span Jeffreys/Firth proper prior `½ log|I(β)|` on the
1335/// coupled joint softmax information, which supplies the `O(1)` curvature that
1336/// bounds exactly those directions and keeps the estimate finite.
1337///
1338/// # The estimator
1339///
1340/// It maximizes the penalized Firth objective at the caller's *fixed* `λ`
1341///
1342/// ```text
1343///   ℓ*(β) = Σ_n w_n Σ_c y_{nc} log p_{nc}
1344///           − ½ Σ_a λ_a βₐᵀ S βₐ
1345///           + ½ log det I(β)
1346/// ```
1347///
1348/// where `I(β)` is the coupled `(P·M)×(P·M)` softmax Fisher information (block
1349/// `(a,b)` is `Σ_n w_n (δ_{ab} p_{na} − p_{na} p_{nb}) x_n x_nᵀ`, block-ordered so
1350/// `θ[a·P+i] = β[i,a]`) and `M = K−1` active classes carry the reference-coded
1351/// logits (`η_{ref} ≡ 0`). The Jeffreys term `½ log det I(β)` is the standard
1352/// Firth penalty: it diverges to `−∞` as any fitted probability approaches the
1353/// simplex boundary (`I → 0`), so its maximizer is interior and finite on exactly
1354/// the separated directions that defeat every smoothing `λ`.
1355///
1356/// # Why this fixed-λ solver rather than the outer-REML formula path
1357///
1358/// The direct entry ([`fit_penalized_multinomial`]) is a fixed-λ inner solve — it
1359/// carries no outer smoothing selection — so the natural Firth engagement is a
1360/// fixed-λ Firth Newton, not the formula path's outer-REML joint-Newton machinery
1361/// (which is armed instead by [`fit_penalized_multinomial_formula`] on separation
1362/// evidence). Solving the Firth objective directly here keeps the separation
1363/// contract self-contained and independent of the shared trust-region/KKT
1364/// certificate machinery.
1365///
1366/// # The iteration
1367///
1368/// A Fisher-scoring Newton on `ℓ*`: the ascent direction is
1369/// `Δ = (I + Λ⊗S)⁻¹ U*`, where `U*` is the Firth-adjusted penalized score
1370///
1371/// ```text
1372///   U*[(c,s)] = Σ_n w_n x_{ns} (y_{nc} − p_{nc})       (data score)
1373///             − λ_c (S β_c)_s                           (smoothing penalty)
1374///             + ½ Σ_n w_n x_{ns} h^c_n                  (Firth adjustment)
1375/// ```
1376///
1377/// and the Firth adjustment uses `h^c_n = Σ_{a,b} G^c_{n,ab} Q_{n,ab}` with the
1378/// per-row information "hat" `Q_{n,ab} = x_nᵀ [I⁻¹]_{(a,b)} x_n` and the softmax
1379/// third-derivative tensor
1380/// `G^c_{ab} = δ_{ab} p_a (δ_{ac} − p_c) − p_a p_b (δ_{ac} + δ_{bc} − 2 p_c)`.
1381/// This `½ Σ tr(I⁻¹ ∂I/∂β)` is exactly `∇[½ log det I]` (finite-difference
1382/// verified). Each step is globalized by backtracking on `ℓ*`, so a step that
1383/// would push a probability to the boundary (making `I` non-PD) is rejected and
1384/// the fit stays interior. Convergence is the Newton decrement `½ U*ᵀΔ`.
1385fn fit_penalized_multinomial_firth_fallback(
1386    design: ArrayView2<'_, f64>,
1387    y_one_hot: ArrayView2<'_, f64>,
1388    penalty: ArrayView2<'_, f64>,
1389    lambdas: ArrayView1<'_, f64>,
1390    row_weights: Option<ArrayView1<'_, f64>>,
1391    max_iter: usize,
1392    tol: f64,
1393    resume_from: Option<FirthResume<'_>>,
1394) -> Result<MultinomialFitOutputs, EstimationError> {
1395    use faer::Side;
1396    use gam_linalg::faer_ndarray::{
1397        FaerArrayView, array1_to_col_matmut, array2_to_matmut, factorize_symmetricwith_fallback,
1398    };
1399    use gam_linalg::matrix::FactorizedSystem;
1400
1401    let n_obs = design.nrows();
1402    let p = design.ncols();
1403    let k = y_one_hot.ncols();
1404    let m = k - 1;
1405    let d = p * m;
1406
1407    // Local softmax likelihood mirroring the caller's row weights, used to map the
1408    // fitted η back to probabilities.
1409    let mut likelihood = MultinomialLogitLikelihood::with_classes(k)?;
1410    if let Some(w) = row_weights.as_ref() {
1411        likelihood = likelihood.with_row_weights(w.to_owned())?;
1412    }
1413    let weight = |row: usize| -> f64 { row_weights.as_ref().map_or(1.0, |w| w[row]) };
1414
1415    let tol_eff = if tol.is_finite() && tol > 0.0 {
1416        tol
1417    } else {
1418        1e-8
1419    };
1420
1421    // Probabilities (N, K), active classes 0..M then the pinned reference at M.
1422    let probs_at = |beta: &Array2<f64>| -> Array2<f64> {
1423        let eta = design.dot(beta);
1424        likelihood.probabilities(eta.view())
1425    };
1426
1427    // Coupled softmax Fisher information I (d×d), block-ordered θ[a·P+i] = β[i,a].
1428    let assemble_info = |probs: &Array2<f64>| -> Array2<f64> {
1429        let mut info = Array2::<f64>::zeros((d, d));
1430        for row in 0..n_obs {
1431            let w = weight(row);
1432            if w == 0.0 {
1433                continue;
1434            }
1435            for a in 0..m {
1436                let pa = probs[[row, a]];
1437                let ao = a * p;
1438                for b in 0..m {
1439                    let pb = probs[[row, b]];
1440                    let wab = w * (if a == b { pa - pa * pb } else { -pa * pb });
1441                    if wab == 0.0 {
1442                        continue;
1443                    }
1444                    let bo = b * p;
1445                    for i in 0..p {
1446                        let xi = design[[row, i]];
1447                        if xi == 0.0 {
1448                            continue;
1449                        }
1450                        let cc = wab * xi;
1451                        for j in 0..p {
1452                            info[[ao + i, bo + j]] += cc * design[[row, j]];
1453                        }
1454                    }
1455                }
1456            }
1457        }
1458        info
1459    };
1460
1461    // Factor a symmetric matrix (with escalating ridge only if it is not SPD) and
1462    // return its inverse and log-determinant.
1463    //
1464    // The ridge ladder is a standard relative-jitter Cholesky recovery, not a
1465    // tuned knob: (a) the base jitter is scaled to the matrix by `max_diag`
1466    // (`max_diag · ε` with ε at the double-precision Cholesky floor ~1e-10) so it
1467    // is invariant to the overall scale of the Fisher information, falling back
1468    // to an absolute floor only when the diagonal is degenerate; (b) it is tried
1469    // first at ridge 0 so an already-SPD matrix is factored unperturbed; (c) it
1470    // grows geometrically (×4) to span the ~120 dB from the base jitter to O(1)
1471    // in a bounded number of steps; (d) the attempt count is capped so a
1472    // genuinely singular information (e.g. an exactly rank-deficient Fisher block)
1473    // surfaces as an explicit error rather than an unbounded loop.
1474    let invert_spd = |mat: &Array2<f64>,
1475                      context: &str|
1476     -> Result<(Array2<f64>, f64), EstimationError> {
1477        let max_diag = (0..d).fold(0.0_f64, |acc, i| acc.max(mat[[i, i]].abs()));
1478        let base = if max_diag.is_finite() && max_diag > 0.0 {
1479            max_diag * 1e-10
1480        } else {
1481            1e-10
1482        };
1483        let mut ridge = 0.0_f64;
1484        for _ in 0..=60 {
1485            let mut ridged = mat.clone();
1486            if ridge > 0.0 {
1487                for i in 0..d {
1488                    ridged[[i, i]] += ridge;
1489                }
1490            }
1491            if let Ok(factor) =
1492                factorize_symmetricwith_fallback(FaerArrayView::new(&ridged).as_ref(), Side::Lower)
1493            {
1494                let logdet = factor.logdet();
1495                if logdet.is_finite() {
1496                    let mut rhs = Array2::<f64>::eye(d);
1497                    {
1498                        let v = array2_to_matmut(&mut rhs);
1499                        factor.solve_in_place(v);
1500                    }
1501                    if rhs.iter().all(|x| x.is_finite()) {
1502                        let mut inv = Array2::<f64>::zeros((d, d));
1503                        for i in 0..d {
1504                            for j in 0..d {
1505                                inv[[i, j]] = 0.5 * (rhs[[i, j]] + rhs[[j, i]]);
1506                            }
1507                        }
1508                        return Ok((inv, logdet));
1509                    }
1510                }
1511            }
1512            ridge = if ridge > 0.0 { ridge * 4.0 } else { base };
1513        }
1514        Err(EstimationError::InvalidInput(format!(
1515            "multinomial Firth fallback: {context} not invertible (max_diag={max_diag:.3e})"
1516        )))
1517    };
1518
1519    // SPD log-determinant only (no ridge): used by the backtracking line search to
1520    // reject any candidate that pushes a fitted probability to the simplex
1521    // boundary (where I loses positive-definiteness and the Firth term → −∞).
1522    let spd_logdet = |mat: &Array2<f64>| -> Option<f64> {
1523        factorize_symmetricwith_fallback(FaerArrayView::new(mat).as_ref(), Side::Lower)
1524            .ok()
1525            .map(|factor| factor.logdet())
1526            .filter(|ld| ld.is_finite())
1527    };
1528
1529    // Penalized Firth objective ℓ* (MAXIMIZED), given probabilities, β, and the
1530    // precomputed log det I(β).
1531    let objective = |probs: &Array2<f64>, beta: &Array2<f64>, logdet_info: f64| -> f64 {
1532        let mut ll = 0.0_f64;
1533        for row in 0..n_obs {
1534            let w = weight(row);
1535            if w == 0.0 {
1536                continue;
1537            }
1538            for c in 0..k {
1539                let ycn = y_one_hot[[row, c]];
1540                if ycn != 0.0 {
1541                    ll += w * ycn * probs[[row, c]].max(f64::MIN_POSITIVE).ln();
1542                }
1543            }
1544        }
1545        // #2344: equivariant per-class penalty ½·Σ_{a,b} A[a,b]·β_aᵀSβ_b —
1546        // the same metric the shared vector-GLM engine applies, so the Firth
1547        // arm optimizes the identical reference-free objective.
1548        let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1549        let mut pen = 0.0_f64;
1550        for a in 0..m {
1551            let bcol = beta.column(a);
1552            for b in 0..m {
1553                let coef = a_mat[[a, b]];
1554                if coef != 0.0 {
1555                    let sbeta = penalty.dot(&beta.column(b));
1556                    pen += 0.5 * coef * bcol.dot(&sbeta);
1557                }
1558            }
1559        }
1560        ll - pen + 0.5 * logdet_info
1561    };
1562
1563    // Firth-adjusted penalized score U* (length d, block-ordered).
1564    let firth_score =
1565        |probs: &Array2<f64>, beta: &Array2<f64>, iinv: &Array2<f64>| -> Array1<f64> {
1566            let mut u = Array1::<f64>::zeros(d);
1567            let mut xn = vec![0.0_f64; p];
1568            let mut pa = vec![0.0_f64; m];
1569            let mut q = vec![0.0_f64; m * m];
1570            for row in 0..n_obs {
1571                let w = weight(row);
1572                if w == 0.0 {
1573                    continue;
1574                }
1575                for i in 0..p {
1576                    xn[i] = design[[row, i]];
1577                }
1578                for a in 0..m {
1579                    pa[a] = probs[[row, a]];
1580                }
1581                // Data score: U[(a,i)] += w x_{ni} (y_{na} − p_{na}).
1582                for a in 0..m {
1583                    let resid = y_one_hot[[row, a]] - pa[a];
1584                    let ao = a * p;
1585                    for i in 0..p {
1586                        u[ao + i] += w * xn[i] * resid;
1587                    }
1588                }
1589                // Per-row information hat Q_{ab} = x_nᵀ [I⁻¹]_{(a,b)} x_n.
1590                for a in 0..m {
1591                    let ao = a * p;
1592                    for b in 0..m {
1593                        let bo = b * p;
1594                        let mut s = 0.0_f64;
1595                        for i in 0..p {
1596                            let xi = xn[i];
1597                            if xi == 0.0 {
1598                                continue;
1599                            }
1600                            let mut inner = 0.0_f64;
1601                            for j in 0..p {
1602                                inner += iinv[[ao + i, bo + j]] * xn[j];
1603                            }
1604                            s += xi * inner;
1605                        }
1606                        q[a * m + b] = s;
1607                    }
1608                }
1609                // Firth adjustment: U[(c,s)] += ½ w x_{ns} h^c_n.
1610                for c in 0..m {
1611                    let pc = pa[c];
1612                    let mut h = 0.0_f64;
1613                    for a in 0..m {
1614                        for b in 0..m {
1615                            let dab = if a == b { 1.0 } else { 0.0 };
1616                            let dac = if a == c { 1.0 } else { 0.0 };
1617                            let dbc = if b == c { 1.0 } else { 0.0 };
1618                            let g =
1619                                dab * pa[a] * (dac - pc) - pa[a] * pa[b] * (dac + dbc - 2.0 * pc);
1620                            h += g * q[a * m + b];
1621                        }
1622                    }
1623                    let co = c * p;
1624                    for s in 0..p {
1625                        u[co + s] += 0.5 * w * h * xn[s];
1626                    }
1627                }
1628            }
1629            // Smoothing penalty gradient (#2344 equivariant metric):
1630            // U[(a,i)] −= Σ_b A[a,b]·(S β_b)_i.
1631            let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1632            for b in 0..m {
1633                let sbeta = penalty.dot(&beta.column(b));
1634                for a in 0..m {
1635                    let coef = a_mat[[a, b]];
1636                    if coef == 0.0 {
1637                        continue;
1638                    }
1639                    let ao = a * p;
1640                    for i in 0..p {
1641                        u[ao + i] -= coef * sbeta[i];
1642                    }
1643                }
1644            }
1645            u
1646        };
1647
1648    // Penalized Hessian H = I + A(λ) ⊗ S (#2344 equivariant metric; PSD sum
1649    // of rank-1 class projections, so H stays positive definite).
1650    let penalized_hessian = |info: &Array2<f64>| -> Array2<f64> {
1651        let mut h = info.clone();
1652        let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1653        for a in 0..m {
1654            for b in 0..m {
1655                let coef = a_mat[[a, b]];
1656                if coef == 0.0 {
1657                    continue;
1658                }
1659                let (ao, bo) = (a * p, b * p);
1660                for i in 0..p {
1661                    for j in 0..p {
1662                        h[[ao + i, bo + j]] += coef * penalty[[i, j]];
1663                    }
1664                }
1665            }
1666        }
1667        h
1668    };
1669
1670    // Solve H Δ = U* for the SPD penalized Hessian, ridge-escalating only on
1671    // factorization failure. Same relative-jitter Cholesky-recovery ladder as
1672    // `invert_spd` above (see its comment for the rationale); the base jitter is
1673    // one decade tighter (`max_diag · 1e-12`) because the penalized Hessian
1674    // solved here is better conditioned than the Fisher information inverted
1675    // there, so a smaller perturbation suffices before escalating.
1676    let solve_spd = |mat: &Array2<f64>,
1677                     rhs: &Array1<f64>|
1678     -> Result<Array1<f64>, EstimationError> {
1679        let max_diag = (0..d).fold(0.0_f64, |acc, i| acc.max(mat[[i, i]].abs()));
1680        let base = if max_diag.is_finite() && max_diag > 0.0 {
1681            max_diag * 1e-12
1682        } else {
1683            1e-12
1684        };
1685        let mut ridge = 0.0_f64;
1686        for _ in 0..=60 {
1687            let mut ridged = mat.clone();
1688            if ridge > 0.0 {
1689                for i in 0..d {
1690                    ridged[[i, i]] += ridge;
1691                }
1692            }
1693            if let Ok(factor) =
1694                factorize_symmetricwith_fallback(FaerArrayView::new(&ridged).as_ref(), Side::Lower)
1695            {
1696                let mut sol = rhs.clone();
1697                {
1698                    let v = array1_to_col_matmut(&mut sol);
1699                    factor.solve_in_place(v);
1700                }
1701                if sol.iter().all(|x| x.is_finite()) {
1702                    return Ok(sol);
1703                }
1704            }
1705            ridge = if ridge > 0.0 { ridge * 4.0 } else { base };
1706        }
1707        Err(EstimationError::InvalidInput(
1708            "multinomial Firth fallback: penalized Hessian solve failed".to_string(),
1709        ))
1710    };
1711
1712    // ─────────────────────────── Firth Newton loop ────────────────────────────
1713    let (mut beta, completed_iterations) = match resume_from {
1714        Some(resume) => {
1715            if resume.coefficients.dim() != (p, m) {
1716                crate::bail_invalid_estim!(
1717                    "multinomial Firth resume coefficient shape {:?} does not match P x (K-1) = {p}x{m}",
1718                    resume.coefficients.dim(),
1719                );
1720            }
1721            (resume.coefficients.to_owned(), resume.completed_iterations)
1722        }
1723        None => (Array2::<f64>::zeros((p, m)), 0),
1724    };
1725    let mut iterations = completed_iterations;
1726    let mut stall_reason = FixedLambdaStallReason::IterationBudgetExhausted;
1727    let mut small_step_reached = false;
1728    for it in 0..max_iter {
1729        iterations = completed_iterations.checked_add(it + 1).ok_or_else(|| {
1730            EstimationError::InvalidInput(
1731                "multinomial Firth resume iteration count overflowed usize".to_string(),
1732            )
1733        })?;
1734        let probs = probs_at(&beta);
1735        let info = assemble_info(&probs);
1736        let (iinv, logdet_info) = invert_spd(&info, "Fisher information")?;
1737        let u = firth_score(&probs, &beta, &iinv);
1738        let hmat = penalized_hessian(&info);
1739        let step_vec = solve_spd(&hmat, &u)?;
1740
1741        // Newton decrement ½ U*ᵀ H⁻¹ U* = ½ U*ᵀ Δ (≥ 0, scale-aware stop).
1742        let decrement = u.dot(&step_vec);
1743        if 0.5 * decrement.abs() < tol_eff {
1744            break;
1745        }
1746
1747        // Δ as (P, M): delta[i, a] = step_vec[a·P + i].
1748        let mut delta = Array2::<f64>::zeros((p, m));
1749        for a in 0..m {
1750            let ao = a * p;
1751            for i in 0..p {
1752                delta[[i, a]] = step_vec[ao + i];
1753            }
1754        }
1755
1756        // Backtracking line search on ℓ* (ascent) via the shared `opt`
1757        // primitive: t₀ = 1, halving up to 60 trials. A candidate whose expected
1758        // information `I` is not SPD (boundary) is an INVALID trial (`Ok(None)`),
1759        // so the search contracts without consulting the acceptance test, keeping
1760        // the iterate interior. The ascent predicate `o1 ≥ o0 − 1e-12` is inlined
1761        // verbatim, so the accepted step is bit-for-bit the hand-rolled loop's.
1762        let o0 = objective(&probs, &beta, logdet_info);
1763        let accepted_step = match backtracking_line_search::<_, Infallible>(
1764            BacktrackConfig::default(),
1765            |step| {
1766                let cand = &beta + &(&delta * step);
1767                let cand_probs = probs_at(&cand);
1768                let cand_info = assemble_info(&cand_probs);
1769                Ok(spd_logdet(&cand_info)
1770                    .map(|cand_logdet| (objective(&cand_probs, &cand, cand_logdet), cand)))
1771            },
1772            |_, o1| o1 >= o0 - 1e-12,
1773        ) {
1774            Ok(result) => result,
1775            Err(never) => match never {},
1776        };
1777        let Some(accepted_step) = accepted_step else {
1778            // Backtracking exhausted 60 halvings without an admissible ascent
1779            // step. This is convergence ONLY if the iterate is already first-order
1780            // stationary; a line-search stall at a non-stationary point is a
1781            // solver failure and must be reported as such, never papered over as
1782            // `converged = true` (#2066 — SPEC: do not report a non-converged
1783            // iterate as success).
1784            //
1785            // The verdict is the loop's OWN stationarity test — the Newton
1786            // decrement `½·Uᵀ H⁻¹ U` against `tol_eff`, the same criterion the top
1787            // of the loop uses to break as converged. A true interior mode never
1788            // reaches this branch: an infinitesimal step (`step → 0`) leaves the
1789            // iterate SPD with `o1 ≈ o0`, so it is accepted; a numerically flat
1790            // mode is caught by the `max_step` test below after that accepted
1791            // tiny step. Reaching here therefore means Newton still sees a
1792            // meaningful ascent direction it cannot realize (boundary / near-
1793            // singular Fisher information), i.e. a genuine stall → not converged.
1794            stall_reason = FixedLambdaStallReason::LineSearchExhausted;
1795            break;
1796        };
1797
1798        let step = accepted_step.step;
1799        beta = accepted_step.payload;
1800        let max_step = step * delta.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1801        let scale = 1.0 + beta.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
1802        if max_step < tol_eff * scale {
1803            small_step_reached = true;
1804            break;
1805        }
1806    }
1807
1808    // ─────────────────────────── final quantities ─────────────────────────────
1809    for (idx, &v) in beta.iter().enumerate() {
1810        if !v.is_finite() {
1811            crate::bail_invalid_estim!(
1812                "multinomial Firth fallback: non-finite coefficient at flat index {idx} = {v}"
1813            );
1814        }
1815    }
1816    let coefficients_active = beta;
1817
1818    let mut log_likelihood = 0.0_f64;
1819    let probs = probs_at(&coefficients_active);
1820    for row in 0..n_obs {
1821        let w = weight(row);
1822        for c in 0..k {
1823            let ycn = y_one_hot[[row, c]];
1824            if ycn != 0.0 {
1825                log_likelihood += w * ycn * probs[[row, c]].max(f64::MIN_POSITIVE).ln();
1826            }
1827        }
1828    }
1829
1830    // #2344 equivariant metric: the reported penalty term matches the
1831    // objective the solve optimized.
1832    let a_mat = crate::penalized_vector_glm::equivariant_class_metric(lambdas, m);
1833    let mut penalty_term = 0.0_f64;
1834    for a in 0..m {
1835        let beta_col = coefficients_active.column(a);
1836        for b in 0..m {
1837            let coef = a_mat[[a, b]];
1838            if coef != 0.0 {
1839                let sbeta = penalty.dot(&coefficients_active.column(b));
1840                penalty_term += 0.5 * coef * beta_col.dot(&sbeta);
1841            }
1842        }
1843    }
1844
1845    // Recompute the Firth score and Newton decrement AT the final accepted
1846    // iterate. A tiny backtracked coefficient step is not itself stationarity:
1847    // only this fresh first-order certificate may authorize construction of a
1848    // fit or its covariance.
1849    let info = assemble_info(&probs);
1850    let (information_inverse, final_logdet_info) = invert_spd(&info, "final Fisher information")?;
1851    let final_score = firth_score(&probs, &coefficients_active, &information_inverse);
1852    let hmat = penalized_hessian(&info);
1853    let final_step = solve_spd(&hmat, &final_score)?;
1854    let final_decrement = 0.5 * final_score.dot(&final_step).abs();
1855    if !(final_decrement.is_finite() && final_decrement < tol_eff) {
1856        if small_step_reached {
1857            stall_reason = FixedLambdaStallReason::StationarityCertificateFailed;
1858        }
1859        // SPEC: a fit object must only ever come from a converged optimization.
1860        // A Firth refit that exhausted its budget (or stalled its line search at
1861        // a non-stationary point) is the typed error carrying its evidence — the
1862        // covariance below is never computed for an uncertified iterate.
1863        let checkpoint = FixedLambdaCheckpoint::new(
1864            FixedLambdaSolverStage::MultinomialFirth,
1865            coefficients_active.iter().copied().collect(),
1866            p,
1867            m,
1868            iterations,
1869        )
1870        .map_err(|reason| {
1871            EstimationError::InvalidInput(format!(
1872                "multinomial Firth fallback produced an invalid internal checkpoint: {reason}"
1873            ))
1874        })?;
1875        return Err(EstimationError::FixedLambdaNewtonDidNotConverge {
1876            context: "fit_penalized_multinomial (Firth/Jeffreys separation refit)".to_string(),
1877            reason: stall_reason,
1878            objective_value: -objective(&probs, &coefficients_active, final_logdet_info),
1879            stationarity: FixedLambdaStationarityEvidence {
1880                kind: FixedLambdaResidualKind::NewtonDecrement,
1881                residual: final_decrement,
1882                bound: tol_eff,
1883            },
1884            checkpoint,
1885        });
1886    }
1887
1888    // Laplace covariance H⁻¹ at the converged mode (block-ordered θ[a·P+i]).
1889    // A covariance that cannot be factored at a certified mode is a hard error,
1890    // never a silent zero matrix (a zero covariance is a false certainty claim).
1891    let (coefficient_covariance, _) = invert_spd(&hmat, "penalized Hessian covariance")?;
1892
1893    Ok(MultinomialFitOutputs {
1894        coefficients_active,
1895        fitted_probabilities: probs,
1896        iterations,
1897        penalized_neg_log_likelihood: -log_likelihood + penalty_term,
1898        deviance: -2.0 * log_likelihood,
1899        coefficient_covariance,
1900    })
1901}
1902
1903// ---------------------------------------------------------------------------
1904// Formula-driven multinomial pipeline
1905// ---------------------------------------------------------------------------
1906//
1907// Slice A of the multinomial integration: a single public entry that takes
1908// a parsed `EncodedDataset`, a Wilkinson-style formula, and a uniform initial
1909// smoothing parameter, then runs the full
1910//
1911//     parse → termspec → design (X, S blocks) → one-hot Y → REML λ-selection
1912//
1913// pipeline. `fit_penalized_multinomial_formula` drives the outer REML/LAML
1914// loop (via the custom-family path) to select an independent λ per (class,
1915// term); `init_lambda` (default 1.0) is only the warm-start seed for every
1916// block. The reference class is the last level of the categorical response
1917// column as recorded in the dataset schema.
1918
1919/// Saved-model payload for a multinomial fit driven by a Wilkinson formula.
1920///
1921/// This is what the FFI returns to Python. It carries everything the Python
1922/// `MultinomialModel.predict` path needs to evaluate `softmax(X_new · β)` on
1923/// fresh data using the *training* basis / penalty structure (no refit on
1924/// predict, no re-derivation of class levels).
1925#[derive(Debug, Clone, Serialize, Deserialize)]
1926#[serde(deny_unknown_fields)]
1927pub struct MultinomialSavedModel {
1928    /// The training formula, verbatim. Stored so Python's `summary()` and
1929    /// any round-trip persistence path can echo what was fit.
1930    pub formula: String,
1931    /// Names of the *training* response levels in canonical order. The last
1932    /// entry is the reference class (η = 0); the first `K - 1` carry the
1933    /// active linear-predictor blocks. Class permutations are forbidden:
1934    /// this list is fixed at fit time and predictions emit columns in the
1935    /// same order.
1936    pub class_levels: Vec<String>,
1937    /// Index of the reference class within `class_levels` — currently always
1938    /// `class_levels.len() - 1`, exposed as a field so future "user-pinned
1939    /// reference" gauges (e.g. `family='multinomial', reference='setosa'`)
1940    /// can land without changing the on-disk shape.
1941    pub reference_class_index: usize,
1942    /// Resolved term-collection spec used to build `X` at fit time. Replayed
1943    /// on predict via [`gam_terms::smooth::build_term_collection_design`].
1944    pub resolved_termspec: TermCollectionSpec,
1945    /// Active-class coefficient block, shape `(P, K-1)`. Column `a` is the
1946    /// coefficient vector for class `class_levels[a]`. Stored flat in
1947    /// row-major order to keep the serde payload self-describing.
1948    pub coefficients_flat: Vec<f64>,
1949    /// `P` — coefficient count per active class. Matches the column count of
1950    /// the design matrix the saved `resolved_termspec` produces.
1951    pub p_per_class: usize,
1952    /// Number of active classes (`K - 1`).
1953    pub n_active_classes: usize,
1954    /// Original training column headers, in dataset-column order. Needed at
1955    /// predict time so the FFI can align a fresh `Dataset` to the training
1956    /// schema before evaluating the basis.
1957    pub training_headers: Vec<String>,
1958    /// Container type of the training table. `"unknown"` is the explicit value
1959    /// for Rust/CLI callers without a typed table container; the field is always
1960    /// present so persistence never invents presentation state while loading.
1961    pub training_table_kind: String,
1962    /// REML/LAML-selected smoothing parameters, one per `(active class, smooth
1963    /// term)`, flattened in block-major order: all of class 0's per-term λ,
1964    /// then class 1's, and so on. Per-term penalties (#561) mean each active
1965    /// class block selects an *independent* λ for every smooth term, so this
1966    /// vector has length `Σ_a (#terms in class a)` = `(K − 1) · #terms`. Use
1967    /// [`MultinomialSavedModel::lambdas_per_block`] to segment it by class. An
1968    /// unpenalized model (no smooth terms) yields an empty vector.
1969    pub lambdas: Vec<f64>,
1970    /// Number of smoothing parameters (smooth terms) in each active class
1971    /// block, parallel to `class_levels[0..K-1]`. Segments the flat `lambdas`
1972    /// vector: class `a`'s λ are `lambdas[Σ_{b<a} lambdas_per_block[b] ..][..
1973    /// lambdas_per_block[a]]`. Every entry is identical in the shared-design
1974    /// architecture (all classes share the same term structure), but it is
1975    /// stored explicitly so consumers never have to assume that.
1976    pub lambdas_per_block: Vec<usize>,
1977    /// Newton iterations executed; recorded for the summary report.
1978    pub iterations: usize,
1979    /// The separation evidence that armed the Jeffreys/Firth proper prior on
1980    /// this fit, or `None` when the unbiased penalized-REML criterion was
1981    /// accepted with the prior disarmed (#2612).
1982    ///
1983    /// The two branches publish **different estimands**. Disarmed, the
1984    /// coefficients are the exact penalized-REML mode with zero Firth bias;
1985    /// armed, they are the mode of that objective plus the proper prior
1986    /// `Φ = ½ log|ZᵀHZ|`, which pulls fitted class probabilities toward the
1987    /// uniform simplex `1/K` by an `O(1/n)` amount that #715 measured as a real
1988    /// truth-RMSE cost on interior data. A consumer scoring calibration, a
1989    /// reader comparing two fits, and the CLI summary all need to know which
1990    /// objective produced the numbers in front of them, and until #2612 the
1991    /// decision existed only in a `log::info!` line the caller never sees.
1992    ///
1993    /// The string is the certificate itself, not a flag: a verdict that carries
1994    /// the spectrum it was taken on can be checked, and one that carries only a
1995    /// boolean cannot.
1996    #[serde(default)]
1997    pub separation_evidence: Option<String>,
1998    /// Penalized negative log-likelihood at the returned `β̂`.
1999    pub penalized_neg_log_likelihood: f64,
2000    /// Unpenalized deviance `−2 log L(β̂)`.
2001    pub deviance: f64,
2002    /// Per-active-class effective degrees of freedom (hat-matrix trace),
2003    /// length `K - 1`. Populated when the REML driver reports an
2004    /// inference block; falls back to `None` for the legacy fixed-λ path.
2005    #[serde(default)]
2006    pub edf_per_class: Option<Vec<f64>>,
2007    /// Per-PENALTY effective degrees of freedom, one entry per smoothing
2008    /// parameter (length `== lambdas.len()`), aligned block-major with the flat
2009    /// [`Self::lambdas`] / [`Self::lambdas_per_block`] layout. Each entry is the
2010    /// penalty-block trace EDF `rank(S_k) − λ_k·tr(H⁻¹ S_k)`, clamped to
2011    /// `[0, rank(S_k)]`. This is the per-(class, term, penalty) resolution that
2012    /// the per-class [`Self::edf_per_class`] SUM deliberately hides: only the
2013    /// per-penalty vector reveals whether an individual smooth collapsed onto its
2014    /// polynomial null space (its wiggliness λ driven to the λ-cap), which a
2015    /// per-class total cannot show. Populated whenever the REML driver reports an
2016    /// inference block; `None` on the legacy fixed-λ path or when the trace
2017    /// channel is mis-shaped. Unlike `edf_per_class`, the entries do NOT sum to
2018    /// the model EDF when several penalties share one coefficient range (a
2019    /// double-penalty smooth has `Σ_k rank(S_k) > p_per_class`).
2020    #[serde(default)]
2021    pub edf_per_penalty: Option<Vec<f64>>,
2022    /// Joint posterior coefficient covariance `H⁻¹` (#1101), block-ordered to
2023    /// match the stacked active-class coefficient vector `β = [β_0; …; β_{K-2}]`
2024    /// (class `a`'s `P` coefficients occupy rows/cols `a·P .. (a+1)·P`). This is
2025    /// the Laplace covariance the REML driver already computes from the factored
2026    /// penalized Hessian; storing it makes posterior-mean prediction and its
2027    /// integrated uncertainty well-defined. Flattened row-major over the
2028    /// `(P·M)×(P·M)` matrix. This is required by the versioned persistence
2029    /// schema: a payload without covariance is not a usable multinomial model.
2030    pub coefficient_covariance_flat: Vec<f64>,
2031    /// The first-order smoothing-parameter-uncertainty correction
2032    /// `C = J·Var(ρ̂)·Jᵀ` (#2346), in the SAME raw units and block order as
2033    /// [`Self::coefficient_covariance_flat`], flattened row-major over the
2034    /// `(P·M)×(P·M)` matrix.
2035    ///
2036    /// `Self::coefficient_covariance_flat` is `V_cond = Var(β | λ̂)`: it answers
2037    /// "how wide is the posterior once the smoothing parameters are known
2038    /// exactly", and λ̂ is an estimate, not a known. Every other family in this
2039    /// library publishes the pair — `InferenceCovarianceMode::{Conditional,
2040    /// SmoothingCorrected}` in `gam-predict`, `beta_covariance` /
2041    /// `beta_covariance_corrected` on `FitInference` — and the multinomial is
2042    /// the one that never got it, so its bands were conditional-only
2043    /// (gam#2612, and the #1871 defect one family over).
2044    ///
2045    /// `C` is stored rather than `V_c = V_cond + C` deliberately: the sum is
2046    /// recoverable exactly by addition (see
2047    /// [`Self::coefficient_covariance_corrected`]) while the difference is not
2048    /// — subtracting two nearly-equal covariances would lose every digit of a
2049    /// correction that is small relative to `V_cond`, which is the regime this
2050    /// matrix is most often in.
2051    ///
2052    /// `None` is a TYPED ABSENCE, never a silent substitution: the outer solve
2053    /// retained no ρ curvature, or every ρ coordinate is railed and so has no
2054    /// finite variance to propagate (#2337 Thm 2.3).
2055    #[serde(default)]
2056    pub smoothing_correction_flat: Option<Vec<f64>>,
2057    /// Joint coefficient-space influence matrix `F = H⁻¹ X'WX` (#1101),
2058    /// block-ordered identically to [`Self::coefficient_covariance_flat`].
2059    /// Its per-term diagonal block trace is the term's effective degrees of
2060    /// freedom and its `tr(F_jj)²/tr(F_jj²)` the Wood reference d.f., feeding
2061    /// the rank-truncated Wald smooth-term test in `summary()`. Flattened
2062    /// row-major over the `(P·M)×(P·M)` matrix. `None` when unavailable.
2063    #[serde(default)]
2064    pub coefficient_influence_flat: Option<Vec<f64>>,
2065    /// Per-(active class, smooth term) coefficient column range and unpenalized
2066    /// nullspace dimension within the `P`-wide class block (#1101). Parallel to
2067    /// the smooth terms the design produced; replicated across classes by the
2068    /// shared-design architecture. Drives the Wald smooth-term table in
2069    /// `summary()`. Empty for a wholly parametric (no-smooth) model.
2070    #[serde(default)]
2071    pub smooth_term_spans: Vec<MultinomialSmoothTermSpan>,
2072    /// Training design in the RAW basis, flattened row-major over `(n, P)`.
2073    ///
2074    /// # Why a saved multinomial model carries its own rows (#2612)
2075    ///
2076    /// The published prediction is the posterior MEAN probability
2077    /// `E[softmax(x'β)]`, and a Laplace summary — `β̂` plus
2078    /// [`Self::coefficient_covariance_flat`] — cannot produce it. That summary
2079    /// IS the quadratic model of the log-posterior, and the quadratic model is
2080    /// exactly what fails: integrating `softmax` against `N(β̂, H⁻¹)` keeps the
2081    /// curvature half of the `O(n⁻¹)` correction to a posterior mean and drops
2082    /// the skewness half, which on a (quasi-)separated fit is neither small nor
2083    /// the same sign. Computing the estimand honestly means evaluating the
2084    /// posterior away from the mode, which means the likelihood, which means the
2085    /// rows. See [`crate::multinomial_predictive`].
2086    ///
2087    /// This is the same choice `mgcv` makes when it stores the model frame with
2088    /// the fitted object, and it is required rather than optional: a payload
2089    /// without it cannot answer the question `predict` is asked.
2090    pub training_design_flat: Vec<f64>,
2091    /// Number of training rows `n`; segments [`Self::training_design_flat`].
2092    pub training_rows: usize,
2093    /// Training class index per row, values in `0..K`, aligned to
2094    /// [`Self::class_levels`].
2095    pub training_class_index: Vec<u32>,
2096    /// Training row weights, length `n`.
2097    pub training_weights: Vec<f64>,
2098    /// The joint penalty `S_λ` at the selected smoothing parameters, flattened
2099    /// row-major over `(P·M, P·M)` in the same stacked class-major order as
2100    /// [`Self::coefficient_covariance_flat`].
2101    ///
2102    /// Stored rather than rebuilt because the coupled `Σ_t λ_t (M ⊗ S_t)`
2103    /// carrier (#1587) is assembled from the family's equivariant specs, which
2104    /// the saved payload does not otherwise carry — and because reconstructing
2105    /// it as `Σ⁻¹(I − F)` would put an inverse of the very matrix whose
2106    /// conditioning is the problem on the prediction path.
2107    pub joint_penalty_flat: Vec<f64>,
2108    /// One descriptive label per *penalty component* within a single active-class
2109    /// block, parallel to that block's λ slice (i.e. length
2110    /// `lambdas_per_block[0]`). The Marra–Wood double penalty (and tensor /
2111    /// operator smooths) emit **more than one** penalty component — hence more
2112    /// than one λ — per smooth term, so this is NOT 1:1 with
2113    /// [`Self::smooth_term_spans`]: a single `s(x)` term contributes a primary
2114    /// wiggliness λ labelled `s(x)` and a null-space shrinkage λ labelled
2115    /// `s(x) [null space]`. The summary renderer pairs `lambdas` with these
2116    /// labels component-for-component so no λ is ever dropped (#1544). Built from
2117    /// the per-component term name + penalty role at fit time; empty only for a
2118    /// wholly parametric model.
2119    pub lambda_labels: Vec<String>,
2120}
2121
2122/// One smooth term's coefficient span within a class block, plus its
2123/// unpenalized nullspace dimension and a display label (#1101). The Wald
2124/// smooth-significance test in `summary()` slices the joint covariance /
2125/// influence at `a·P + col_start .. a·P + col_end` for active class `a`.
2126#[derive(Debug, Clone, Serialize, Deserialize)]
2127pub struct MultinomialSmoothTermSpan {
2128    /// Human-readable term label (the smooth's formula token), for the table.
2129    pub label: String,
2130    /// Start column of the term within the per-class `P`-wide coefficient block.
2131    pub col_start: usize,
2132    /// End column (exclusive) of the term within the per-class block.
2133    pub col_end: usize,
2134    /// Leading unpenalized (polynomial nullspace) dimension within the term.
2135    pub nullspace_dim: usize,
2136}
2137
2138/// Descriptive label for one penalty *component* (one λ) within a class block,
2139/// for the `summary()` per-class λ rollup (#1544). A smooth term can emit
2140/// several penalty components — the Marra–Wood double penalty splits `s(x)`
2141/// into a primary wiggliness penalty and a null-space shrinkage penalty, and
2142/// tensor / operator smooths emit a component per margin / differential
2143/// operator — each with its own independently-selected λ. The label is the
2144/// term name (from `PenaltyBlockInfo::termname`) plus a role suffix derived
2145/// from the penalty's [`PenaltySource`], so each λ in the summary names both
2146/// the term it smooths and the role it plays. `pen_idx` is the global penalty
2147/// index, used only as a last-resort fallback label.
2148fn penalty_component_label(info: Option<&PenaltyBlockInfo>, pen_idx: usize) -> String {
2149    use gam_terms::basis::PenaltySource;
2150    let term = info
2151        .and_then(|i| i.termname.clone())
2152        .unwrap_or_else(|| format!("s{pen_idx}"));
2153    let role = match info.map(|i| &i.penalty.source) {
2154        // The primary wiggliness penalty is the term's "main" λ; show the bare
2155        // term name so the common single-penalty case reads cleanly.
2156        Some(PenaltySource::Primary) | None => None,
2157        Some(PenaltySource::DoublePenaltyNullspace) => Some("null space".to_string()),
2158        Some(PenaltySource::OperatorMass) => Some("mass".to_string()),
2159        Some(PenaltySource::OperatorTension) => Some("tension".to_string()),
2160        Some(PenaltySource::OperatorStiffness) => Some("stiffness".to_string()),
2161        Some(PenaltySource::OperatorRelevance { axis }) => Some(format!("axis {axis}")),
2162        Some(PenaltySource::TensorMarginal { dim }) => Some(format!("margin {dim}")),
2163        Some(PenaltySource::TensorSeparable { penalized_margins }) => {
2164            Some(format!("separable {penalized_margins:?}"))
2165        }
2166        Some(PenaltySource::TensorGlobalRidge) => Some("ridge".to_string()),
2167        Some(PenaltySource::Other(s)) => Some(s.clone()),
2168    };
2169    match role {
2170        Some(role) => format!("{term} [{role}]"),
2171        None => term,
2172    }
2173}
2174
2175impl MultinomialSavedModel {
2176    pub fn validate(&self) -> Result<(), EstimationError> {
2177        if self.p_per_class == 0 || self.n_active_classes == 0 {
2178            crate::bail_invalid_estim!(
2179                "multinomial saved model dimensions must be nonzero, got P={} and K-1={}",
2180                self.p_per_class,
2181                self.n_active_classes,
2182            );
2183        }
2184        if self.class_levels.len() != self.n_active_classes + 1 {
2185            crate::bail_invalid_estim!(
2186                "multinomial saved model has {} class levels but K-1={}",
2187                self.class_levels.len(),
2188                self.n_active_classes,
2189            );
2190        }
2191        if self.reference_class_index != self.n_active_classes {
2192            crate::bail_invalid_estim!(
2193                "multinomial saved reference index {} does not equal the final class index {}",
2194                self.reference_class_index,
2195                self.n_active_classes,
2196            );
2197        }
2198        let d = self
2199            .p_per_class
2200            .checked_mul(self.n_active_classes)
2201            .ok_or_else(|| {
2202                EstimationError::InvalidInput(
2203                    "multinomial saved coefficient dimension overflowed usize".to_string(),
2204                )
2205            })?;
2206        if self.coefficients_flat.len() != d {
2207            crate::bail_invalid_estim!(
2208                "multinomial saved model has {} coefficient values, expected {d}",
2209                self.coefficients_flat.len(),
2210            );
2211        }
2212        if self.training_table_kind.trim().is_empty() {
2213            crate::bail_invalid_estim!(
2214                "multinomial saved model training_table_kind must be non-empty"
2215            );
2216        }
2217        if self.lambdas_per_block.len() != self.n_active_classes {
2218            crate::bail_invalid_estim!(
2219                "multinomial saved model has {} lambda blocks, expected {}",
2220                self.lambdas_per_block.len(),
2221                self.n_active_classes,
2222            );
2223        }
2224        let lambda_count = self
2225            .lambdas_per_block
2226            .iter()
2227            .try_fold(0usize, |total, &count| total.checked_add(count))
2228            .ok_or_else(|| {
2229                EstimationError::InvalidInput(
2230                    "multinomial saved lambda count overflowed usize".to_string(),
2231                )
2232            })?;
2233        if lambda_count != self.lambdas.len() {
2234            crate::bail_invalid_estim!(
2235                "multinomial saved model has {} lambdas but its blocks require {lambda_count}",
2236                self.lambdas.len(),
2237            );
2238        }
2239        if self
2240            .lambdas_per_block
2241            .iter()
2242            .any(|&count| count != self.lambda_labels.len())
2243        {
2244            crate::bail_invalid_estim!(
2245                "multinomial saved model has {} lambda labels but block sizes {:?}",
2246                self.lambda_labels.len(),
2247                self.lambdas_per_block,
2248            );
2249        }
2250        if self
2251            .lambda_labels
2252            .iter()
2253            .any(|label| label.trim().is_empty())
2254        {
2255            crate::bail_invalid_estim!("multinomial saved model lambda labels must be non-empty");
2256        }
2257        let covariance_len = d.checked_mul(d).ok_or_else(|| {
2258            EstimationError::InvalidInput(
2259                "multinomial saved covariance dimension overflowed usize".to_string(),
2260            )
2261        })?;
2262        if self.coefficient_covariance_flat.len() != covariance_len {
2263            crate::bail_invalid_estim!(
2264                "multinomial saved model has {} covariance values, expected {covariance_len}",
2265                self.coefficient_covariance_flat.len(),
2266            );
2267        }
2268        if let Some(correction) = self.smoothing_correction_flat.as_ref() {
2269            // A correction that is present but mis-shaped is worse than one that
2270            // is absent: absence is typed and the consumers fall back to the
2271            // conditional definition, while a wrong shape would silently pair a
2272            // ρ-uncertainty term with the wrong coefficients.
2273            if correction.len() != covariance_len {
2274                crate::bail_invalid_estim!(
2275                    "multinomial saved model has {} smoothing-correction values, expected \
2276                     {covariance_len}",
2277                    correction.len(),
2278                );
2279            }
2280        }
2281        if let Some((index, value)) = self
2282            .coefficients_flat
2283            .iter()
2284            .chain(self.coefficient_covariance_flat.iter())
2285            .chain(self.smoothing_correction_flat.iter().flat_map(|c| c.iter()))
2286            .copied()
2287            .enumerate()
2288            .find(|(_, value)| !value.is_finite())
2289        {
2290            crate::bail_invalid_estim!(
2291                "multinomial saved numeric payload is non-finite at combined index {index}: {value}"
2292            );
2293        }
2294        // #2612: the training frame is what makes the published posterior mean
2295        // computable at all, so its shape is a contract, not a hint.
2296        let design_len = self
2297            .training_rows
2298            .checked_mul(self.p_per_class)
2299            .ok_or_else(|| {
2300                EstimationError::InvalidInput(
2301                    "multinomial saved training design size overflowed usize".to_string(),
2302                )
2303            })?;
2304        if self.training_design_flat.len() != design_len {
2305            crate::bail_invalid_estim!(
2306                "multinomial saved model has {} training design values, expected \
2307                 {design_len} = {} rows x {} columns",
2308                self.training_design_flat.len(),
2309                self.training_rows,
2310                self.p_per_class,
2311            );
2312        }
2313        if self.training_class_index.len() != self.training_rows
2314            || self.training_weights.len() != self.training_rows
2315        {
2316            crate::bail_invalid_estim!(
2317                "multinomial saved model has {} training rows, {} labels and {} weights",
2318                self.training_rows,
2319                self.training_class_index.len(),
2320                self.training_weights.len(),
2321            );
2322        }
2323        let n_classes = self.class_levels.len();
2324        if let Some(label) = self
2325            .training_class_index
2326            .iter()
2327            .find(|&&label| label as usize >= n_classes)
2328        {
2329            crate::bail_invalid_estim!(
2330                "multinomial saved training label {label} is outside 0..{n_classes}"
2331            );
2332        }
2333        if self.joint_penalty_flat.len() != covariance_len {
2334            crate::bail_invalid_estim!(
2335                "multinomial saved model has {} joint-penalty values, expected {covariance_len}",
2336                self.joint_penalty_flat.len(),
2337            );
2338        }
2339        if let Some((index, value)) = self
2340            .training_design_flat
2341            .iter()
2342            .chain(self.training_weights.iter())
2343            .chain(self.joint_penalty_flat.iter())
2344            .copied()
2345            .enumerate()
2346            .find(|(_, value)| !value.is_finite())
2347        {
2348            crate::bail_invalid_estim!(
2349                "multinomial saved training payload is non-finite at combined index \
2350                 {index}: {value}"
2351            );
2352        }
2353        Ok(())
2354    }
2355
2356    /// The training frame and penalty this model carries, as the borrowed view
2357    /// [`crate::multinomial_predictive`] consumes.
2358    pub fn predictive_model<'a>(
2359        &'a self,
2360        training_design: ndarray::ArrayView2<'a, f64>,
2361        training_weights: ndarray::ArrayView1<'a, f64>,
2362        joint_penalty: ndarray::ArrayView2<'a, f64>,
2363    ) -> crate::multinomial_predictive::MultinomialPredictiveModel<'a> {
2364        crate::multinomial_predictive::MultinomialPredictiveModel {
2365            training_design,
2366            training_class_index: &self.training_class_index,
2367            training_weights,
2368            joint_penalty,
2369            n_classes: self.class_levels.len(),
2370        }
2371    }
2372
2373    /// Training design as an `(n, P)` `ndarray`.
2374    pub fn training_design(&self) -> Result<Array2<f64>, EstimationError> {
2375        Array2::from_shape_vec(
2376            (self.training_rows, self.p_per_class),
2377            self.training_design_flat.clone(),
2378        )
2379        .map_err(|error| {
2380            EstimationError::InvalidInput(format!(
2381                "multinomial saved training design is inconsistent with n x P: {error}"
2382            ))
2383        })
2384    }
2385
2386    /// Joint penalty `S_λ` as a `(P·M, P·M)` `ndarray`.
2387    pub fn joint_penalty(&self) -> Result<Array2<f64>, EstimationError> {
2388        let d = self
2389            .p_per_class
2390            .checked_mul(self.n_active_classes)
2391            .ok_or_else(|| {
2392                EstimationError::InvalidInput(
2393                    "multinomial saved joint-penalty dimension overflowed usize".to_string(),
2394                )
2395            })?;
2396        Array2::from_shape_vec((d, d), self.joint_penalty_flat.clone()).map_err(|error| {
2397            EstimationError::InvalidInput(format!(
2398                "multinomial saved joint penalty is inconsistent with (P·M)x(P·M): {error}"
2399            ))
2400        })
2401    }
2402
2403    /// Active-class coefficient block as an `(P, K-1)` `ndarray` view.
2404    pub fn coefficients_active(&self) -> Result<Array2<f64>, EstimationError> {
2405        Array2::from_shape_vec(
2406            (self.p_per_class, self.n_active_classes),
2407            self.coefficients_flat.clone(),
2408        )
2409        .map_err(|error| {
2410            EstimationError::InvalidInput(format!(
2411                "multinomial saved coefficient payload is inconsistent with P x (K-1): {error}"
2412            ))
2413        })
2414    }
2415
2416    /// Reconstruct the joint posterior covariance `H⁻¹` as a `(P·M)×(P·M)`
2417    /// `ndarray`, block-ordered to match the stacked coefficient vector
2418    /// `θ[a·P + i] = β[i, a]` (#1101).
2419    pub fn coefficient_covariance(&self) -> Result<Array2<f64>, EstimationError> {
2420        let d = self
2421            .p_per_class
2422            .checked_mul(self.n_active_classes)
2423            .ok_or_else(|| {
2424                EstimationError::InvalidInput(
2425                    "multinomial saved covariance dimension overflowed usize".to_string(),
2426                )
2427            })?;
2428        Array2::from_shape_vec((d, d), self.coefficient_covariance_flat.clone()).map_err(|error| {
2429            EstimationError::InvalidInput(format!(
2430                "multinomial saved covariance payload is inconsistent with (P*(K-1)) squared: {error}"
2431            ))
2432        })
2433    }
2434
2435    /// Reconstruct the first-order smoothing correction `C = J·Var(ρ̂)·Jᵀ` as a
2436    /// `(P·M)×(P·M)` `ndarray`, block-ordered like
2437    /// [`Self::coefficient_covariance`] (#2346, gam#2612). `None` is the typed
2438    /// absence documented on [`Self::smoothing_correction_flat`].
2439    pub fn smoothing_correction(&self) -> Option<Array2<f64>> {
2440        let d = self.p_per_class.checked_mul(self.n_active_classes)?;
2441        let flat = self.smoothing_correction_flat.as_ref()?;
2442        Array2::from_shape_vec((d, d), flat.clone()).ok()
2443    }
2444
2445    /// The smoothing-CORRECTED joint posterior covariance `V_c = V_cond + C`,
2446    /// the multinomial's `InferenceCovarianceMode::SmoothingCorrected` matrix.
2447    ///
2448    /// `None` exactly when [`Self::smoothing_correction`] is `None`. This never
2449    /// falls back to the conditional matrix: a caller that asks for the
2450    /// unconditional covariance and is handed the conditional one cannot tell
2451    /// that its interval is narrower than it asked for, which is the whole
2452    /// defect the mode axis exists to make visible.
2453    pub fn coefficient_covariance_corrected(&self) -> Option<Array2<f64>> {
2454        let correction = self.smoothing_correction()?;
2455        let conditional = self.coefficient_covariance().ok()?;
2456        if conditional.dim() != correction.dim() {
2457            return None;
2458        }
2459        Some(conditional + correction)
2460    }
2461
2462    /// Reconstruct the joint influence matrix `F = H⁻¹ X'WX` as a
2463    /// `(P·M)×(P·M)` `ndarray`, block-ordered like
2464    /// [`Self::coefficient_covariance`] (#1101). `None` when unavailable.
2465    pub fn coefficient_influence(&self) -> Option<Array2<f64>> {
2466        let d = self.p_per_class.checked_mul(self.n_active_classes)?;
2467        let flat = self.coefficient_influence_flat.as_ref()?;
2468        Array2::from_shape_vec((d, d), flat.clone()).ok()
2469    }
2470
2471    /// Default posterior-mean class probabilities. This integrates
2472    /// `softmax(eta)` under the per-row Gaussian predictor posterior rather than
2473    /// evaluating softmax at the coefficient mode.
2474    pub fn predict_probabilities(
2475        &self,
2476        x_new: ArrayView2<'_, f64>,
2477    ) -> Result<Array2<f64>, EstimationError> {
2478        // Second moments cost `K(K+1)/2` extra augmented solves per row and
2479        // nothing here reads them, so the mean-only caller does not pay for
2480        // them.
2481        self.predictive_moments(x_new, false)
2482            .map(|moments| moments.class_mean)
2483    }
2484
2485    /// Posterior-mean class probabilities and integrated marginal standard
2486    /// deviations at fresh design rows, under the covariance definition this
2487    /// model can support — [`Self::predict_probabilities_with_se_in_mode`] with
2488    /// `SmoothingCorrected` when the correction is present and `Conditional`
2489    /// when it is not.
2490    ///
2491    /// The DEFAULT is the corrected definition, matching
2492    /// `PredictUncertaintyOptions::default()` for every other family
2493    /// (`gam-predict`), because `λ̂` is an estimate and a band that conditions on
2494    /// it being exact is answering a narrower question than the caller asked.
2495    pub fn predict_probabilities_with_se(
2496        &self,
2497        x_new: ArrayView2<'_, f64>,
2498    ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
2499        let (mean, standard_error, _) = self.predict_probabilities_with_se_and_source(x_new)?;
2500        Ok((mean, standard_error))
2501    }
2502
2503    /// The same pair, plus the covariance definition it was actually built
2504    /// from. A caller that cannot tell a conditional band from an unconditional
2505    /// one cannot reason about the interval it was handed, so the provenance is
2506    /// returned rather than inferred.
2507    pub fn predict_probabilities_with_se_and_source(
2508        &self,
2509        x_new: ArrayView2<'_, f64>,
2510    ) -> Result<(Array2<f64>, Array2<f64>, InferenceCovarianceMode), EstimationError> {
2511        let source = if self.smoothing_correction_flat.is_some() {
2512            InferenceCovarianceMode::SmoothingCorrected
2513        } else {
2514            InferenceCovarianceMode::Conditional
2515        };
2516        let (mean, standard_error) = self.predict_probabilities_with_se_in_mode(x_new, source)?;
2517        Ok((mean, standard_error, source))
2518    }
2519
2520    /// Posterior-mean class probabilities and their marginal standard
2521    /// deviations under an explicitly named covariance definition.
2522    ///
2523    /// # What the two modes are
2524    ///
2525    /// [`InferenceCovarianceMode::Conditional`] is `sd(p_c | λ̂)`, computed by
2526    /// [`crate::multinomial_predictive`] as `√(E[p_c²] − E[p_c]²)` from two
2527    /// ratios of normalising constants at the selected smoothing. It is the
2528    /// exact posterior spread of the probability GIVEN that `λ̂` is the truth.
2529    ///
2530    /// [`InferenceCovarianceMode::SmoothingCorrected`] additionally propagates
2531    /// the uncertainty in `λ̂` itself, by the law of total variance:
2532    ///
2533    /// ```text
2534    ///     Var(p_c) = E_ρ[ Var(p_c | ρ) ]  +  Var_ρ( E[p_c | ρ] )
2535    ///              ≈ Var(p_c | ρ̂)         +  (∂E[p_c|ρ]/∂ρ)ᵀ V_ρ (∂E[p_c|ρ]/∂ρ)
2536    /// ```
2537    ///
2538    /// and the second term needs no new object. To the order the correction is
2539    /// itself computed at, `E[p_c | ρ]` moves with `ρ` through the mode
2540    /// `θ̂(ρ)`, so `∂E[p_c|ρ]/∂ρ = gᵀ ∂θ̂/∂ρ` with `g = ∂p_c/∂θ` the softmax
2541    /// Jacobian at the mode, and
2542    ///
2543    /// ```text
2544    ///     Var_ρ( E[p_c|ρ] ) = gᵀ (J V_ρ Jᵀ) g = gᵀ C g
2545    /// ```
2546    ///
2547    /// with `C` exactly the matrix [`Self::smoothing_correction`] carries. That
2548    /// is the response-scale statement of `V_c = V_cond + C`: the same
2549    /// correction, contracted through the same delta-method Jacobian the rest of
2550    /// the library uses on the response scale, with no new constant and no new
2551    /// approximation order.
2552    ///
2553    /// `g` is evaluated at the PLUG-IN probabilities `softmax(x'β̂)` rather than
2554    /// at the published posterior mean. Those differ by `O(n⁻¹)`, and `g`
2555    /// multiplies a term that is itself the first-order correction, so the
2556    /// difference enters at an order neither term is accurate to; the plug-in
2557    /// Jacobian is the exact derivative of the leading term.
2558    ///
2559    /// # Errors
2560    ///
2561    /// Asking for `SmoothingCorrected` on a model that carries no correction is
2562    /// an ERROR, not a silent downgrade — the same contract
2563    /// `gam-predict`'s `SmoothingCorrected` mode holds.
2564    pub fn predict_probabilities_with_se_in_mode(
2565        &self,
2566        x_new: ArrayView2<'_, f64>,
2567        mode: InferenceCovarianceMode,
2568    ) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
2569        let moments = self.predictive_moments(x_new, true)?;
2570        let conditional = crate::multinomial_predictive::predictive_standard_deviation(&moments)?;
2571        match mode {
2572            InferenceCovarianceMode::Conditional => Ok((moments.class_mean, conditional)),
2573            InferenceCovarianceMode::SmoothingCorrected => {
2574                let correction = self.smoothing_correction().ok_or_else(|| {
2575                    EstimationError::InvalidInput(
2576                        "multinomial predict: the smoothing-corrected covariance was requested \
2577                         but this fit retained no ρ-uncertainty correction (no outer ρ curvature, \
2578                         or every ρ coordinate is railed and has no finite variance); ask for \
2579                         the conditional definition by name rather than receiving it unannounced"
2580                            .to_string(),
2581                    )
2582                })?;
2583                let smoothing_variance =
2584                    self.smoothing_variance_of_class_probability(x_new, correction.view())?;
2585                let mut total = conditional;
2586                for ((row, class), value) in total.indexed_iter_mut() {
2587                    *value = (*value * *value + smoothing_variance[[row, class]]).sqrt();
2588                }
2589                Ok((moments.class_mean, total))
2590            }
2591        }
2592    }
2593
2594    /// `gᵀ C g` per (row, class): the response-scale variance the smoothing
2595    /// parameters' own uncertainty contributes to the mean class probability.
2596    ///
2597    /// The softmax Jacobian factorises as `g_c = u_c ⊗ x` with
2598    /// `u_c[a] = p_c(δ_{ca} − p_a)` over the `M` ACTIVE logits (the reference
2599    /// class `c = K−1` is included and simply has `δ_{ca} = 0` for every active
2600    /// `a`), so the whole `(row, class)` table costs one `M×M` Gram
2601    /// `Q[a,b] = xᵀ C_{ab} x` per row and then `K` quadratic forms of size `M`
2602    /// against it — never a `d`-dimensional contraction per class.
2603    fn smoothing_variance_of_class_probability(
2604        &self,
2605        x_new: ArrayView2<'_, f64>,
2606        correction: ArrayView2<'_, f64>,
2607    ) -> Result<Array2<f64>, EstimationError> {
2608        let p = self.p_per_class;
2609        let m = self.n_active_classes;
2610        let k = self.class_levels.len();
2611        let d = p * m;
2612        if correction.dim() != (d, d) {
2613            crate::bail_invalid_estim!(
2614                "multinomial predict: smoothing correction is {}x{}, expected {d}x{d}",
2615                correction.nrows(),
2616                correction.ncols(),
2617            );
2618        }
2619        if x_new.ncols() != p {
2620            crate::bail_invalid_estim!(
2621                "multinomial predict: design has {} columns, expected {p}",
2622                x_new.ncols(),
2623            );
2624        }
2625        let coefficients = self.coefficients_active()?;
2626        let rows = x_new.nrows();
2627        let mut out = Array2::<f64>::zeros((rows, k));
2628        let mut gram = Array2::<f64>::zeros((m, m));
2629        // `C x_b` for each active block `b`, reused across `a` and across classes.
2630        let mut correction_times_x = Array2::<f64>::zeros((m, d));
2631        for row in 0..rows {
2632            let x = x_new.row(row);
2633            for b in 0..m {
2634                for i in 0..d {
2635                    let mut acc = 0.0;
2636                    for j in 0..p {
2637                        acc += correction[[i, b * p + j]] * x[j];
2638                    }
2639                    correction_times_x[[b, i]] = acc;
2640                }
2641            }
2642            for a in 0..m {
2643                for b in 0..m {
2644                    let mut acc = 0.0;
2645                    for j in 0..p {
2646                        acc += correction_times_x[[b, a * p + j]] * x[j];
2647                    }
2648                    gram[[a, b]] = acc;
2649                }
2650            }
2651            let eta: Vec<f64> = (0..m)
2652                .map(|a| (0..p).map(|i| x[i] * coefficients[[i, a]]).sum::<f64>())
2653                .collect();
2654            let probabilities = softmax_with_reference(&eta)?;
2655            for class in 0..k {
2656                let jacobian: Vec<f64> = (0..m)
2657                    .map(|a| {
2658                        let delta = if class == a { 1.0 } else { 0.0 };
2659                        probabilities[class] * (delta - probabilities[a])
2660                    })
2661                    .collect();
2662                let mut variance = 0.0;
2663                for a in 0..m {
2664                    for b in 0..m {
2665                        variance += jacobian[a] * gram[[a, b]] * jacobian[b];
2666                    }
2667                }
2668                // `C` is PSD by construction (`J V_ρ Jᵀ` with `V_ρ` PSD), so a
2669                // negative quadratic form here is round-off on a direction the
2670                // correction does not reach, not a measurement. Clamping at zero
2671                // is exact for that case and the alternative — a NaN from
2672                // `sqrt` — would destroy an otherwise valid band.
2673                out[[row, class]] = variance.max(0.0);
2674            }
2675        }
2676        Ok(out)
2677    }
2678
2679    /// The posterior mode in the stacked class-major order the joint covariance,
2680    /// the joint penalty and [`crate::multinomial_predictive`] all use:
2681    /// `θ[a·P + i] = β[i, a]`.
2682    pub fn stacked_mode(&self) -> Result<Array1<f64>, EstimationError> {
2683        let coefficients = self.coefficients_active()?;
2684        let p = self.p_per_class;
2685        let m = self.n_active_classes;
2686        let mut theta = Array1::<f64>::zeros(p * m);
2687        for a in 0..m {
2688            for i in 0..p {
2689                theta[a * p + i] = coefficients[[i, a]];
2690            }
2691        }
2692        Ok(theta)
2693    }
2694
2695    /// Posterior-predictive moments at fresh design rows, by the ratio of
2696    /// normalising constants (#2612).
2697    ///
2698    /// The Smolyak accuracy/level control this method used to take is gone
2699    /// rather than ignored. That control existed because the old mechanism was
2700    /// a quadrature whose answer could be bought with more nodes; this one is
2701    /// not, and its accuracy is a property of the expansion (`O(n⁻²)`) that no
2702    /// amount of extra work changes. What replaces it is a per-row exactness
2703    /// check the caller does not have to configure: `Σ_c E[p_c] = 1` is an
2704    /// identity of the estimand, so the deviation of the computed sum from one
2705    /// IS the approximation's error at that row, and a row past
2706    /// [`crate::multinomial_predictive::PREDICTIVE_MASS_DEFECT_TOLERANCE`] is
2707    /// refused rather than published. See [`crate::multinomial_predictive`] for
2708    /// why integrating `softmax` over `N(β̂, H⁻¹)` is not an approximation of
2709    /// this estimand at all.
2710    fn predictive_moments(
2711        &self,
2712        x_new: ArrayView2<'_, f64>,
2713        want_second_moments: bool,
2714    ) -> Result<crate::multinomial_predictive::MultinomialPredictiveMoments, EstimationError> {
2715        let design = self.training_design()?;
2716        let penalty = self.joint_penalty()?;
2717        let weights = Array1::from(self.training_weights.clone());
2718        let mode = self.stacked_mode()?;
2719        let model = self.predictive_model(design.view(), weights.view(), penalty.view());
2720        model.predictive_moments(mode.view(), x_new, want_second_moments)
2721    }
2722
2723    /// Wood (2013) rank-truncated Wald smooth-significance test per
2724    /// `(active class, smooth term)` (#1101), reusing the exact scalar-summary
2725    /// kernel [`gam_terms::inference::smooth_test::wood_smooth_test`]. For active
2726    /// class `a` and term span `[c0, c1)` within the class block, the global
2727    /// coefficient range is `a·P + c0 .. a·P + c1`; the joint covariance and
2728    /// influence are sliced there. The term EDF is the influence-block trace
2729    /// `tr(F_jj)` (when present) and the reference d.f. uses `tr(F_jj)²/tr(F_jj²)`,
2730    /// exactly as the scalar path. The multinomial softmax is a known-dispersion
2731    /// family, so the χ²_{ref_df} branch applies. Returns one row per
2732    /// `(class label, term label, edf, ref_df, statistic, p_value)`; empty when
2733    /// no covariance/smooth terms are available.
2734    pub fn smooth_significance(&self) -> Vec<MultinomialSmoothSignificance> {
2735        let mut out = Vec::new();
2736        let p = self.p_per_class;
2737        let m = self.n_active_classes;
2738        let Ok(cov) = self.coefficient_covariance() else {
2739            return out;
2740        };
2741        if self.smooth_term_spans.is_empty() {
2742            return out;
2743        }
2744        let Ok(beta) = self.coefficients_active() else {
2745            return out;
2746        };
2747        // Block-ordered θ = [β_0; …; β_{M-1}], θ[a·P + i] = β[i, a].
2748        let d = p * m;
2749        let mut theta = Array1::<f64>::zeros(d);
2750        for a in 0..m {
2751            for i in 0..p {
2752                theta[a * p + i] = beta[[i, a]];
2753            }
2754        }
2755        let influence = self.coefficient_influence();
2756        for a in 0..m {
2757            let class_label = self
2758                .class_levels
2759                .get(a)
2760                .cloned()
2761                .unwrap_or_else(|| format!("class{a}"));
2762            let base = a * p;
2763            for span in &self.smooth_term_spans {
2764                if span.col_end > p {
2765                    continue;
2766                }
2767                let start = base + span.col_start;
2768                let end = base + span.col_end;
2769                // Term EDF = tr(F_jj); without an influence matrix fall back to
2770                // the block coefficient count (full-rank Wald on the span).
2771                let block_len = (span.col_end - span.col_start) as f64;
2772                let edf = influence
2773                    .as_ref()
2774                    .map(|f| (start..end).map(|i| f[[i, i]]).sum::<f64>())
2775                    .filter(|v| v.is_finite() && *v > 0.0)
2776                    .unwrap_or(block_len);
2777                let result = gam_terms::inference::smooth_test::wood_smooth_test(
2778                    gam_terms::inference::smooth_test::SmoothTestInput {
2779                        beta: theta.view(),
2780                        covariance: &cov,
2781                        influence_matrix: influence.as_ref(),
2782                        whitening_gram: None,
2783                        coeff_range: start..end,
2784                        edf,
2785                        nullspace_dim: span.nullspace_dim,
2786                        residual_df: None,
2787                        scale: gam_terms::inference::smooth_test::SmoothTestScale::Known,
2788                    },
2789                );
2790                if let Some(res) = result {
2791                    out.push(MultinomialSmoothSignificance {
2792                        class_label: class_label.clone(),
2793                        term_label: span.label.clone(),
2794                        edf,
2795                        ref_df: res.ref_df,
2796                        statistic: res.statistic,
2797                        p_value: res.p_value,
2798                    });
2799                }
2800            }
2801        }
2802        out
2803    }
2804
2805    /// Draw `n_draws` posterior-predictive replicate class assignments at fresh
2806    /// rows (#1101). Each draw independently samples every row's class from
2807    /// `Categorical(p_row)` with `p = E[softmax(eta) | data]`, so coefficient
2808    /// uncertainty is integrated before adding categorical observation noise.
2809    /// The returned `(n_draws, N)` matrix holds class
2810    /// INDICES `0..K`, aligned to [`Self::class_levels`]. The draw stream is a
2811    /// `StdRng` seeded by `seed`, so `(x_new, n_draws, seed)` reproduce
2812    /// bit-identically — the engine for posterior-predictive checks and
2813    /// simulation-based calibration. `x_new` must have `self.p_per_class`
2814    /// columns (built from the same `resolved_termspec` as fit time).
2815    pub fn sample_replicate_classes(
2816        &self,
2817        x_new: ArrayView2<'_, f64>,
2818        n_draws: usize,
2819        seed: u64,
2820    ) -> Result<Array2<u32>, EstimationError> {
2821        use rand::{RngExt, SeedableRng};
2822        let probs = self.predict_probabilities(x_new)?;
2823        let n = probs.nrows();
2824        let k = probs.ncols();
2825        let mut out = Array2::<u32>::zeros((n_draws, n));
2826        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
2827        for d in 0..n_draws {
2828            for row in 0..n {
2829                let u: f64 = rng.random::<f64>();
2830                // Inverse-CDF categorical draw over the K simplex weights.
2831                let mut acc = 0.0_f64;
2832                let mut chosen = k - 1; // numerical fallback = reference class
2833                for c in 0..k {
2834                    acc += probs[[row, c]];
2835                    if u < acc {
2836                        chosen = c;
2837                        break;
2838                    }
2839                }
2840                out[[d, row]] = chosen as u32;
2841            }
2842        }
2843        Ok(out)
2844    }
2845}
2846
2847/// On-disk `model_class` discriminator for a persisted multinomial model. Kept
2848/// as a single constant so every producer / consumer of the envelope agrees on
2849/// the tag without a scattered string literal.
2850pub const MULTINOMIAL_MODEL_CLASS: &str = "multinomial";
2851/// Exact multinomial persistence schema. Version 2 requires the canonical
2852/// per-component lambda labels and training-table provenance; successful
2853/// deserialization therefore yields a complete current model without repair.
2854pub const MULTINOMIAL_MODEL_FORMAT_VERSION: u32 = 2;
2855
2856/// Round-trip persistence envelope for a fitted multinomial model. The
2857/// `model_class` discriminator lets a loader tell a multinomial payload apart
2858/// from the scalar `FittedModel` JSON before deserialising the whole struct.
2859///
2860/// This is the single definition of the multinomial on-disk format, shared by
2861/// the Python FFI (`fit_multinomial_formula` / `predict_multinomial_formula`)
2862/// and the `gam` CLI (`gam fit --family multinomial` / `gam predict`), so a
2863/// model persisted by one surface loads in the other.
2864#[derive(Debug, Clone, Serialize, Deserialize)]
2865#[serde(deny_unknown_fields)]
2866pub struct MultinomialModelEnvelope {
2867    pub model_class: String,
2868    pub format_version: u32,
2869    pub saved: MultinomialSavedModel,
2870}
2871
2872impl MultinomialModelEnvelope {
2873    /// Wrap a fitted model with the canonical `model_class` tag.
2874    pub fn new(saved: MultinomialSavedModel) -> Result<Self, EstimationError> {
2875        saved.validate()?;
2876        Ok(Self {
2877            model_class: MULTINOMIAL_MODEL_CLASS.to_string(),
2878            format_version: MULTINOMIAL_MODEL_FORMAT_VERSION,
2879            saved,
2880        })
2881    }
2882
2883    /// Serialize to the canonical JSON byte payload.
2884    pub fn to_json_bytes(&self) -> Result<Vec<u8>, EstimationError> {
2885        self.saved.validate()?;
2886        serde_json::to_vec(self).map_err(|err| {
2887            EstimationError::InvalidInput(format!("failed to serialize multinomial model: {err}"))
2888        })
2889    }
2890
2891    /// Parse an envelope from JSON bytes, validating the `model_class`
2892    /// discriminator so a non-multinomial payload is rejected with a clear
2893    /// error rather than silently mis-predicted.
2894    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self, EstimationError> {
2895        // Gate on the envelope header (`model_class` + `format_version`) *before*
2896        // deserializing the versioned `saved` body. The header parse ignores the
2897        // body, so a payload that predates a field which has since become
2898        // required (e.g. `saved.formula`) is rejected on the version gate rather
2899        // than on whatever inner field is now missing — the version check is the
2900        // contract that tells a caller their payload is stale, and it must fire
2901        // first regardless of how the body schema has since evolved.
2902        #[derive(Deserialize)]
2903        struct EnvelopeHeader {
2904            #[serde(default)]
2905            model_class: Option<String>,
2906            #[serde(default)]
2907            format_version: Option<u32>,
2908        }
2909        let header: EnvelopeHeader = serde_json::from_slice(bytes).map_err(|err| {
2910            EstimationError::InvalidInput(format!("failed to deserialize multinomial model: {err}"))
2911        })?;
2912        match header.model_class.as_deref() {
2913            Some(MULTINOMIAL_MODEL_CLASS) => {}
2914            other => {
2915                return Err(EstimationError::InvalidInput(format!(
2916                    "multinomial model: model_class = {other:?}, expected {MULTINOMIAL_MODEL_CLASS:?}",
2917                )));
2918            }
2919        }
2920        match header.format_version {
2921            Some(MULTINOMIAL_MODEL_FORMAT_VERSION) => {}
2922            Some(version) => {
2923                return Err(EstimationError::InvalidInput(format!(
2924                    "multinomial model: format_version = {version}, expected {MULTINOMIAL_MODEL_FORMAT_VERSION}",
2925                )));
2926            }
2927            None => {
2928                return Err(EstimationError::InvalidInput(format!(
2929                    "multinomial model: format_version is absent (unversioned payload), expected {MULTINOMIAL_MODEL_FORMAT_VERSION}",
2930                )));
2931            }
2932        }
2933        let envelope: Self = serde_json::from_slice(bytes).map_err(|err| {
2934            EstimationError::InvalidInput(format!("failed to deserialize multinomial model: {err}"))
2935        })?;
2936        envelope.saved.validate()?;
2937        Ok(envelope)
2938    }
2939}
2940
2941#[cfg(test)]
2942mod multinomial_persistence_contract_tests {
2943    use super::*;
2944
2945    #[test]
2946    fn unversioned_payload_is_rejected() {
2947        let payload = br#"{"model_class":"multinomial","saved":{}}"#;
2948        let error = MultinomialModelEnvelope::from_json_bytes(payload)
2949            .expect_err("unversioned multinomial persistence must not be guessed");
2950        assert!(
2951            error.to_string().contains("format_version"),
2952            "unexpected persistence error: {error}"
2953        );
2954    }
2955}
2956
2957/// One row of the multinomial smooth-significance table (#1101): the Wood
2958/// rank-truncated Wald test for one `(active class, smooth term)` pair.
2959#[derive(Debug, Clone)]
2960pub struct MultinomialSmoothSignificance {
2961    pub class_label: String,
2962    pub term_label: String,
2963    pub edf: f64,
2964    pub ref_df: f64,
2965    pub statistic: f64,
2966    pub p_value: f64,
2967}
2968
2969/// One-hot-encode the categorical response column and return both the
2970/// encoding and the captured level names. The level order matches the order
2971/// recorded in the dataset schema, which is the canonical (lexicographically
2972/// sorted) factor order produced by inferred-schema construction (#1319) — so
2973/// it is a deterministic function of the label *set*, independent of training
2974/// row order (no silent class permutation under a row shuffle), and matches the
2975/// R `factor()` / pandas `Categorical` convention.
2976fn one_hot_categorical_response(
2977    data: &EncodedDataset,
2978    y_col: usize,
2979    response_name: &str,
2980) -> Result<(Array2<f64>, Vec<String>), EstimationError> {
2981    let levels: Vec<String> = data
2982        .schema
2983        .columns
2984        .get(y_col)
2985        .map(|sc| sc.levels.clone())
2986        .unwrap_or_default();
2987    if levels.len() < 2 {
2988        crate::bail_invalid_estim!(
2989            "multinomial response '{response_name}' must have at least 2 categorical levels (got {})",
2990            levels.len()
2991        );
2992    }
2993    let n = data.values.nrows();
2994    let k = levels.len();
2995    let mut y_one_hot = Array2::<f64>::zeros((n, k));
2996    for row in 0..n {
2997        let encoded = data.values[[row, y_col]];
2998        if !encoded.is_finite() {
2999            crate::bail_invalid_estim!(
3000                "multinomial response '{response_name}' row {row} is non-finite ({encoded})"
3001            );
3002        }
3003        let class_idx = encoded.round() as i64;
3004        if class_idx < 0 || (class_idx as usize) >= k {
3005            crate::bail_invalid_estim!(
3006                "multinomial response '{response_name}' row {row} encoded as {encoded} \
3007                 is outside the level range 0..{k}"
3008            );
3009        }
3010        y_one_hot[[row, class_idx as usize]] = 1.0;
3011    }
3012    Ok((y_one_hot, levels))
3013}
3014
3015/// Build `(TermCollectionSpec, TermCollectionDesign)` from a formula against
3016/// a categorical-response dataset. Mirrors the early scaffolding inside
3017/// `materialize_standard` (response role resolution, geometry-aware spec
3018/// build) without touching the scalar-family resolution path — multinomial
3019/// owns its own response kind check.
3020fn build_formula_design_for_multinomial(
3021    formula: &str,
3022    data: &EncodedDataset,
3023    config: &FitConfig,
3024) -> Result<
3025    (
3026        TermCollectionSpec,
3027        TermCollectionDesign,
3028        usize,
3029        String,
3030        ResponseColumnKind,
3031    ),
3032    EstimationError,
3033> {
3034    let parsed = parse_formula(formula).map_err(|err| {
3035        EstimationError::InvalidInput(format!(
3036            "multinomial fit: failed to parse formula {formula:?}: {err}"
3037        ))
3038    })?;
3039    let col_map = data.column_map();
3040    let y_col = resolve_role_col(&col_map, &parsed.response, "response")
3041        .map_err(|err| EstimationError::InvalidInput(format!("multinomial fit: {err}")))?;
3042    let y_kind = crate::fit_orchestration::response_column_kind(data, y_col);
3043    let policy = resolved_resource_policy(config, ProblemHints::default());
3044    let mut inference_notes: Vec<String> = Vec::new();
3045    let spec = build_termspec_with_geometry_and_overrides(
3046        &parsed.terms,
3047        data,
3048        &col_map,
3049        &mut inference_notes,
3050        config.scale_dimensions,
3051        &policy,
3052        config.smooth_overrides.as_ref(),
3053        None,
3054    )
3055    .map_err(|err| {
3056        EstimationError::InvalidInput(format!("multinomial fit: build termspec: {err}"))
3057    })?;
3058    let design = build_term_collection_design(data.values.view(), &spec).map_err(|err| {
3059        EstimationError::InvalidInput(format!("multinomial fit: build design: {err}"))
3060    })?;
3061    if design.affine_offset.iter().any(|value| *value != 0.0) {
3062        crate::bail_invalid_estim!(
3063            "multinomial fit does not support non-zero smooth anchors: the reference-coded \
3064             softmax requires an explicit affine offset for every non-reference class"
3065        );
3066    }
3067    Ok((spec, design, y_col, parsed.response, y_kind))
3068}
3069
3070fn scale_multinomial_formula_penalty(penalty: PenaltyMatrix, scale: f64) -> PenaltyMatrix {
3071    match penalty {
3072        PenaltyMatrix::Dense(matrix) => PenaltyMatrix::Dense(matrix.mapv(|v| v * scale)),
3073        PenaltyMatrix::KroneckerFactored { left, right } => PenaltyMatrix::KroneckerFactored {
3074            left: left.mapv(|v| v * scale),
3075            right,
3076        },
3077        PenaltyMatrix::Blockwise {
3078            local,
3079            col_range,
3080            total_dim,
3081        } => PenaltyMatrix::Blockwise {
3082            local: local.mapv(|v| v * scale),
3083            col_range,
3084            total_dim,
3085        },
3086        PenaltyMatrix::Labeled { label, inner } => PenaltyMatrix::Labeled {
3087            label,
3088            inner: Box::new(scale_multinomial_formula_penalty(*inner, scale)),
3089        },
3090        PenaltyMatrix::Fixed { log_lambda, inner } => PenaltyMatrix::Fixed {
3091            log_lambda,
3092            inner: Box::new(scale_multinomial_formula_penalty(*inner, scale)),
3093        },
3094    }
3095}
3096
3097/// Canonical typed inputs for the formula-driven multinomial fit
3098/// ([`fit_penalized_multinomial_formula`]).
3099///
3100/// Every frontend (Rust, CLI, Python FFI) builds this one request, so the
3101/// warm-start / outer-search defaults live here rather than being duplicated
3102/// per caller. `config` is the same canonical [`FitConfig`] the scalar formula
3103/// families consume: `weight_column` is resolved against the dataset and
3104/// honored as per-row case weights, and fields the softmax family cannot
3105/// consume (offsets, noise/log-slope formulas, manual Firth, frailty, ...) are
3106/// rejected with a typed error instead of being silently dropped.
3107#[derive(Clone, Copy)]
3108pub struct MultinomialFitRequest<'a> {
3109    pub data: &'a EncodedDataset,
3110    pub formula: &'a str,
3111    pub config: &'a FitConfig,
3112    /// Warm-start seed for every per-(class, term) smoothing parameter; λ is
3113    /// REML/LAML-selected, so this only seeds the outer search.
3114    pub init_lambda: f64,
3115    /// OUTER REML/LAML smoothing-parameter iteration budget.
3116    pub max_iter: usize,
3117    /// Requested accuracy; drives the inner joint-Newton KKT target (see the
3118    /// control-split note inside the fit).
3119    pub tol: f64,
3120}
3121
3122impl<'a> MultinomialFitRequest<'a> {
3123    /// The canonical production controls shared by the CLI and the Python FFI.
3124    pub fn new(data: &'a EncodedDataset, formula: &'a str, config: &'a FitConfig) -> Self {
3125        Self {
3126            data,
3127            formula,
3128            config,
3129            init_lambda: 1.0,
3130            max_iter: 50,
3131            tol: 1.0e-7,
3132        }
3133    }
3134}
3135
3136/// Reject canonical-config fields the softmax multinomial family cannot
3137/// consume. Silently dropping a requested offset / noise model / manual Firth
3138/// toggle would quietly change the estimand the caller asked for (SPEC 3), so
3139/// every unsupported field is a typed error shared by all frontends.
3140fn reject_unsupported_multinomial_config(config: &FitConfig) -> Result<(), EstimationError> {
3141    if config.offset_column.is_some() || config.noise_offset_column.is_some() {
3142        crate::bail_invalid_estim!(
3143            "multinomial fit does not support offset columns: a single offset column has no \
3144             canonical per-logit placement in the reference-coded softmax (offsets are per-class \
3145             linear-predictor quantities); remove the offset or fit per-class models"
3146        );
3147    }
3148    if config.noise_formula.is_some() {
3149        crate::bail_invalid_estim!(
3150            "noise_formula is not supported for the multinomial family: the softmax likelihood \
3151             has no dispersion predictor"
3152        );
3153    }
3154    if config.logslope_formula.is_some() || config.z_column.is_some() {
3155        crate::bail_invalid_estim!(
3156            "logslope_formula/z_column is not supported for the multinomial family"
3157        );
3158    }
3159    if config.transformation_normal {
3160        crate::bail_invalid_estim!("transformation_normal conflicts with the multinomial family");
3161    }
3162    if config.expectile_tau.is_some() {
3163        crate::bail_invalid_estim!("expectile_tau requires the expectile family");
3164    }
3165    if config.firth {
3166        crate::bail_invalid_estim!(
3167            "manual firth is not accepted for the multinomial family: the Firth/Jeffreys \
3168             separation stabilizer is armed automatically on separation evidence"
3169        );
3170    }
3171    if !matches!(
3172        config.frailty,
3173        crate::survival::lognormal_kernel::FrailtySpec::None
3174    ) {
3175        crate::bail_invalid_estim!("frailty is not supported for the multinomial family");
3176    }
3177    Ok(())
3178}
3179
3180/// Resolve the canonical `weight_column` into per-row case weights (`None` ⇒
3181/// uniform 1.0). Finiteness / non-negativity are enforced by
3182/// [`MultinomialFamily::new`], which owns the weight contract.
3183fn resolve_multinomial_row_weights(
3184    data: &EncodedDataset,
3185    config: &FitConfig,
3186) -> Result<Array1<f64>, EstimationError> {
3187    let Some(name) = config.weight_column.as_deref() else {
3188        return Ok(Array1::ones(data.values.nrows()));
3189    };
3190    let column = data.column_map().get(name).copied().ok_or_else(|| {
3191        EstimationError::InvalidInput(format!(
3192            "multinomial fit: weight column '{name}' not found in the dataset"
3193        ))
3194    })?;
3195    Ok(data.values.column(column).to_owned())
3196}
3197
3198/// Top-level formula-driven multinomial fit.
3199///
3200/// Routes through [`fit_custom_family_with_rho_prior`] so the per-active-class
3201/// smoothing parameters `λ_a` (one per class block, shared-penalty
3202/// architecture) are selected by the outer REML/LAML loop rather than pinned
3203/// by the caller. `init_lambda` survives as a warm-start hint that seeds
3204/// every block's `initial_log_lambdas`. `max_iter` / `tol` drive the OUTER
3205/// REML/LAML smoothing-parameter search (`outer_max_iter` / `outer_tol`); the
3206/// inner joint-Newton solve runs on the framework's principled production cycle
3207/// budget at the default KKT tolerance so an ill-conditioned, LM-damped
3208/// near-simplex-boundary solve can certify a stationary point instead of being
3209/// declared non-converged after only `max_iter` cycles (#715).
3210///
3211/// The Jeffreys/Firth proper prior is engaged CONDITIONALLY: attempt 1 runs
3212/// the unbiased penalized-REML criterion; only on separation evidence (a failed
3213/// solve, a non-finite logit, or an exact full-span PENALIZED-curvature conditioning
3214/// certificate; see [`multinomial_formula_penalized_separation_evidence`]) is the
3215/// fit re-solved once with the full-span Firth prior armed, which bounds the
3216/// penalty-null directions no smoothing parameter can (`S v = 0` ⇒
3217/// `(H + S_λ) v = H v → 0` when the softmax likelihood has no identified finite
3218/// mode).
3219///
3220/// The categorical response column is recognised via the dataset schema
3221/// (`ColumnKindTag::Categorical`); reference class = last level. Returns a
3222/// [`MultinomialSavedModel`] that can be serialised to bytes for the Python
3223/// wrapper or used in-process for `predict_probabilities`.
3224/// Everything [`fit_penalized_multinomial_formula`] constructs BEFORE the REML
3225/// solve: the family (unbiased criterion, Jeffreys disarmed), the per-class
3226/// block specs with seeded `initial_log_lambdas`, the calibrated solver
3227/// options, and the design artifacts the post-solve repack consumes. Exposed
3228/// at crate level so diagnostics can drive the EXACT production objective at
3229/// fixed smoothing parameters (finite-difference gates on the outer ρ-gradient
3230/// of the coalesced joint penalty family, #2349) instead of replicating the
3231/// construction and diverging from it.
3232pub(crate) struct PenalizedMultinomialFormulaParts {
3233    pub(crate) family: MultinomialFamily,
3234    pub(crate) blocks: Vec<ParameterBlockSpec>,
3235    pub(crate) options: BlockwiseFitOptions,
3236    pub(crate) spec: TermCollectionSpec,
3237    pub(crate) design: TermCollectionDesign,
3238    pub(crate) class_levels: Vec<String>,
3239    pub(crate) parametric_standardization: Vec<(usize, f64, f64)>,
3240    pub(crate) penalties_arc: Arc<Vec<PenaltyMatrix>>,
3241    /// The design BEFORE the parametric standardization — the basis the saved
3242    /// coefficients and the predict-time rebuild share, and therefore the one
3243    /// the posterior-predictive ratio evaluates the likelihood in (#2612).
3244    pub(crate) raw_training_design: Array2<f64>,
3245    /// Training class index per row, values in `0..K` (#2612).
3246    pub(crate) training_class_index: Vec<u32>,
3247    /// Resolved per-row case weights (#2612).
3248    pub(crate) training_weights: Array1<f64>,
3249}
3250
3251pub(crate) fn penalized_multinomial_formula_parts(
3252    request: &MultinomialFitRequest<'_>,
3253) -> Result<PenalizedMultinomialFormulaParts, EstimationError> {
3254    let MultinomialFitRequest {
3255        data,
3256        formula,
3257        config,
3258        init_lambda,
3259        max_iter,
3260        tol,
3261    } = *request;
3262    if !(init_lambda.is_finite() && init_lambda > 0.0) {
3263        crate::bail_invalid_estim!(
3264            "multinomial fit: init_lambda must be finite and > 0 (got {init_lambda})"
3265        );
3266    }
3267    reject_unsupported_multinomial_config(config)?;
3268    let (raw_spec, design, y_col, response_name, y_kind) =
3269        build_formula_design_for_multinomial(formula, data, config)?;
3270    // Freeze the data-derived basis state (B-spline knot vectors, by-factor
3271    // level sets, spatial centers, joint-null rotations, residualization
3272    // charts) from the fit design back onto the spec. The raw geometry spec
3273    // records only *which* columns and *what kind* of basis each smooth uses;
3274    // the actual column count and basis evaluation depend on quantities the
3275    // builder derives from the training data (knot placement, the distinct
3276    // by-factor levels, etc.). Saving the raw spec made predict re-derive those
3277    // from the (smaller, differently-distributed) predict frame, so the rebuilt
3278    // design had a different column count than the fitted one — the panic
3279    // "predict design has 42 cols, saved model expects 191" for an `s(x,
3280    // by=group)` smooth-by-factor model. Every other family's persistence path
3281    // freezes the spec the same way (see `freeze_term_collection_from_design`
3282    // call sites in `main_parts`); multinomial was the lone exception.
3283    let spec = freeze_term_collection_from_design(&raw_spec, &design)?;
3284    let class_levels = match y_kind {
3285        ResponseColumnKind::Categorical { levels } => levels,
3286        ResponseColumnKind::Binary => vec!["0".to_string(), "1".to_string()],
3287        ResponseColumnKind::Numeric => {
3288            crate::bail_invalid_estim!(
3289                "multinomial fit: response '{response_name}' is numeric, not categorical; \
3290                 use family='gaussian'/'binomial'/... or convert the column to a categorical type"
3291            );
3292        }
3293    };
3294    // A Binary response is promoted to a 2-level categorical for the
3295    // multinomial driver: the caller explicitly asked for multinomial, so we
3296    // route through the K-1 = 1 active-class softmax (equivalent math to
3297    // logistic). Anything that is neither Binary nor Categorical has no class
3298    // set to softmax over.
3299    let y_column_kind = data.column_kinds.get(y_col);
3300    if y_column_kind != Some(&ColumnKindTag::Binary)
3301        && y_column_kind != Some(&ColumnKindTag::Categorical)
3302    {
3303        crate::bail_invalid_estim!(
3304            "multinomial fit: response '{response_name}' must be a categorical column \
3305             (got column kind {:?})",
3306            y_column_kind
3307        );
3308    }
3309    let (y_one_hot, _) = one_hot_categorical_response(data, y_col, &response_name)?;
3310    // Build the global X dense (the design is a DesignMatrix abstraction).
3311    let mut x_dense = design
3312        .design
3313        .try_to_dense_by_chunks("multinomial fit design")
3314        .map_err(EstimationError::InvalidInput)?;
3315
3316    // ── #715 real-data conditioning: standardize unpenalized parametric
3317    // columns. Raw-unit linear covariates (penguins `body_mass_g` ~ 4e3 grams)
3318    // inflate the joint Newton information by the squared column scale (a κ(H)
3319    // multiplier of ~s² ≈ 1e7 against the intercept), which is what turns the
3320    // near-separable LM-damped inner solve into a geometric grind that
3321    // exhausts its cycle budgets — the adapter-level face of "all REML startup
3322    // seeds rejected". Because these columns are UNPENALIZED (parametric terms
3323    // carry no default ridge, #749), the affine reparameterization
3324    // `x_j ↦ (x_j − m_j)/s_j` is EXACT for the whole criterion: the optimized
3325    // REML/LAML objective, the fitted η, the selected λ, and the separation
3326    // diagnostics are all invariant — only the conditioning of `H` changes.
3327    // Fitted coefficients are mapped back to raw units at repack below, so the
3328    // saved model and the (raw-design) predict path are untouched. Penalized
3329    // columns are left alone (a penalty makes the rescaling non-equivalent),
3330    // and nothing is touched when explicit coefficient bounds/constraints
3331    // exist (those are stated in raw units).
3332    // #2612: the RAW design, before the parametric standardization below, is the
3333    // basis the saved coefficients and the predict-time rebuild both live in, so
3334    // it is the one the posterior-predictive ratio must evaluate the likelihood
3335    // in. Snapshot it here rather than un-standardizing later: an inverse map
3336    // applied to a matrix is a second source of truth for the same rows.
3337    let raw_training_design = x_dense.clone();
3338    let parametric_standardization: Vec<(usize, f64, f64)> =
3339        if design.coefficient_lower_bounds.is_some() || design.linear_constraints.is_some() {
3340            Vec::new()
3341        } else {
3342            let p_total = x_dense.ncols();
3343            let mut penalized = vec![false; p_total];
3344            for bp in &design.penalties {
3345                for col in bp.col_range.clone() {
3346                    if col < p_total {
3347                        penalized[col] = true;
3348                    }
3349                }
3350            }
3351            let has_intercept = !design.intercept_range.is_empty();
3352            let n_rows = x_dense.nrows().max(1) as f64;
3353            let mut standardized = Vec::new();
3354            for (_, range) in &design.linear_ranges {
3355                for col in range.clone() {
3356                    if col >= p_total || penalized[col] {
3357                        continue;
3358                    }
3359                    let column = x_dense.column(col);
3360                    let mean = column.sum() / n_rows;
3361                    let var = column.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n_rows;
3362                    let scale = var.sqrt();
3363                    // Skip near-constant or degenerate columns: no conditioning to
3364                    // be gained and the back-map would divide by ~0.
3365                    if !(scale.is_finite() && scale > 1e-8 * (mean.abs() + 1.0)) {
3366                        continue;
3367                    }
3368                    // Centering shifts mass onto the intercept; without one the
3369                    // shift is not representable, so scale only.
3370                    let center = if has_intercept { mean } else { 0.0 };
3371                    for v in x_dense.column_mut(col).iter_mut() {
3372                        *v = (*v - center) / scale;
3373                    }
3374                    standardized.push((col, center, scale));
3375                }
3376            }
3377            standardized
3378        };
3379    // Preserve the per-smooth-term penalty block structure (#561): each smooth
3380    // term `t` contributes its own `P × P` penalty component (`Blockwise` with
3381    // `total_dim = P`, the term's local `S_t` embedded at its `col_range`), and
3382    // every active class block receives the FULL list. The outer REML/LAML loop
3383    // then selects an independent smoothing parameter λ_{a,t} per (class, term),
3384    // matching mgcv/VGAM. Pre-summing the terms into one fused `S` (the prior
3385    // behaviour) forced a single λ per class that scales `Σ_t S_t`, so one
3386    // shared λ had to over-smooth a rough term while under-smoothing a smooth
3387    // one — biasing any multi-term class-probability surface.
3388    let k = y_one_hot.ncols();
3389    let n_obs = y_one_hot.nrows();
3390    let penalty_scale = multinomial_formula_penalty_scale(k);
3391    let per_term_penalties: Vec<PenaltyMatrix> = design
3392        .penalties_as_penalty_matrix()
3393        .into_iter()
3394        .map(|penalty| scale_multinomial_formula_penalty(penalty, penalty_scale))
3395        .collect();
3396
3397    // ── Custom-family driven REML/LAML path ───────────────────────────────
3398    // Each active class becomes one ParameterBlockSpec, all sharing X and the
3399    // per-term penalty list. `initial_log_lambdas` is seeded from the caller's
3400    // `init_lambda` (one entry per term).
3401    let design_arc = Arc::new(x_dense);
3402    let penalties_arc = Arc::new(per_term_penalties);
3403    let weights = resolve_multinomial_row_weights(data, config)?;
3404    if weights.len() != n_obs {
3405        crate::bail_invalid_estim!(
3406            "multinomial fit: weight column length {} != N = {n_obs}",
3407            weights.len()
3408        );
3409    }
3410    // #2612: the class index the posterior-predictive ratio appends its extra
3411    // row's label from. Read off the one-hot the fit itself validated, so the
3412    // two cannot disagree about which class a row is.
3413    let training_class_index: Vec<u32> = (0..n_obs)
3414        .map(|row| {
3415            let mut best = 0usize;
3416            for class in 1..k {
3417                if y_one_hot[[row, class]] > y_one_hot[[row, best]] {
3418                    best = class;
3419                }
3420            }
3421            best as u32
3422        })
3423        .collect();
3424    let training_weights = weights.clone();
3425    // First attempt runs the UNBIASED penalized-REML criterion (no Firth
3426    // shrinkage toward the uniform simplex); the Jeffreys/Firth proper prior is
3427    // armed conditionally below, only on separation evidence (#715/#753 — see
3428    // `multinomial_formula_separation_evidence`).
3429    let log_init = init_lambda.ln();
3430    let family = MultinomialFamily::new(
3431        y_one_hot.clone(),
3432        weights,
3433        k,
3434        design_arc.clone(),
3435        penalties_arc.clone(),
3436    )
3437    .map_err(EstimationError::InvalidInput)?
3438    .with_joint_jeffreys_term(false)
3439    // gam#1587: the per-block smooth penalties are emptied (the centered `M⊗S_t`
3440    // joint penalty is the sole smoothing carrier), so the `init_lambda` warm
3441    // start must seed the JOINT penalty's `initial_log_lambda` — the per-block
3442    // `initial_log_lambdas` loop below is now a no-op (empty per-block list).
3443    .with_initial_log_lambda(log_init);
3444    let mut blocks = family.build_block_specs();
3445    for spec_block in blocks.iter_mut() {
3446        for v in spec_block.initial_log_lambdas.iter_mut() {
3447            *v = log_init;
3448        }
3449    }
3450
3451    // ── Outer-derivative policy: dimension-gated exact curvature ────────────
3452    // Medium-D formula fits need exact curvature to keep lambda selection away
3453    // from over-smoothed caps, while smooth-by-factor models still avoid the
3454    // O(D²) dense Hessian path.
3455    //
3456    // `D` is the number of coordinates the OUTER search actually has, which is
3457    // the joint penalty spec count and NOT `(K−1) · n_penalties`. Under the
3458    // equivariant carrier (#1587) each penalty component emits one spec PER
3459    // CLASS, so a `K = 3` model carries `3·n_penalties` coordinates; the
3460    // per-block product this used to compute has not been the outer dimension
3461    // for any `K > 2` model since that landing. On the four-smooth penguin
3462    // fixture the two differ by 50% (`8·3 = 24` against `2·8 = 16`), which put
3463    // it on the opposite side of the threshold from the one
3464    // `MULTINOMIAL_EXACT_OUTER_HESSIAN_MAX_DIM`'s own doc block says it was
3465    // chosen to keep it on.
3466    let total_rho_dim = family.joint_smoothing_dimension();
3467    let use_outer_hessian = multinomial_formula_use_outer_hessian(total_rho_dim);
3468
3469    // ── Inner-vs-outer control split (#715 non-convergence root cause) ────────
3470    // The legacy `max_iter` / `tol` parameters are the *outer* REML/LAML
3471    // smoothing-parameter optimization controls — "how hard to search λ". The
3472    // earlier wiring routed them straight into `inner_max_cycles` / `inner_tol`,
3473    // capping the joint-Newton inner solve at `max_iter` (=50 in the quality
3474    // suite) cycles with a `tol`-tight (=1e-8) KKT target. That is the #715
3475    // hang: near the simplex boundary the softmax Fisher weight
3476    // `W = diag(p) − p pᵀ` collapses, so `H = JᵀWJ + S_λ` is full-rank but
3477    // ILL-CONDITIONED. The self-vanishing Levenberg–Marquardt damping
3478    // (`levenberg_on_ill_conditioning()`) that keeps the inner solve from
3479    // oscillating on those near-singular modes makes it converge only
3480    // GEOMETRICALLY (linearly), not quadratically. Reaching a 1e-8 relative KKT
3481    // residual under geometric descent needs FAR more than 50 cycles, so the
3482    // inner returned `converged = false` on every outer ρ-evaluation; with the
3483    // exact-Hessian outer optimizer on `FallbackPolicy::Disabled` that rejects
3484    // every ρ-step — each rejected eval still paying a near-full 50-cycle inner
3485    // solve plus the O(D²) pairwise outer-Hessian directional work — so the
3486    // outer never certifies and the fit runs unbounded (the observed >8-minute
3487    // non-termination). The certificate cannot be reached, not merely slow.
3488    //
3489    // Fix: give the INNER joint-Newton the framework's principled production
3490    // budget (`DEFAULT_CUSTOM_FAMILY_INNER_MAX_CYCLES` cycles at the default
3491    // `inner_tol`), which exists precisely so an ill-conditioned LM-damped solve
3492    // can certify a stationary KKT point instead of being declared non-converged
3493    // prematurely — and the KKT/objective certificates still exit in a handful
3494    // of cycles on the well-conditioned interior fits, so this is free there.
3495    // The caller's `max_iter` / `tol` become the OUTER controls they were always
3496    // meant to be (smoothing-parameter search depth / accuracy). The inner KKT
3497    // target is kept no tighter than the outer accuracy can consume — and no
3498    // tighter than the softmax objective's f64 noise floor on near-separable
3499    // fits (see `MULTINOMIAL_FORMULA_INNER_TOL`).
3500    let outer_max_iter = max_iter.max(1);
3501    // The OUTER REML/LAML smoothing-parameter search must converge to a
3502    // well-calibrated ρ-gradient tolerance, NOT to the caller's (typically very
3503    // tight) INNER KKT tolerance. The #715 control-split repurposed the caller's
3504    // `tol` as the outer control, but feeding an inner-scale `tol = 1e-8`
3505    // straight into `outer_tol` makes REML grind dozens of extra exact-gradient
3506    // outer iterations (each an O(D·p³) Laplace-derivative assembly over the full
3507    // P·M joint design) to squeeze ρ digits that no longer move the fitted
3508    // surface — the smooth-by-factor 269s wall-clock overrun (#1082).
3509    //
3510    // The right target is the framework's CALIBRATED REML convergence tolerance,
3511    // `MULTINOMIAL_OUTER_REML_TOL = 1e-7` — the same value the primary GLM REML
3512    // outer uses (`solver::fit_orchestration::materialize` `tol: 1e-7`, mirrored by the
3513    // `LOG_LAMBDA_TOL`/`KKT_TOL_*` constants across the REML stack). At 1e-7 the
3514    // λ-search reaches the genuine REML optimum (so the recovered probability
3515    // surface matches the mature reference), but it does NOT chase the last
3516    // surface-irrelevant ρ digits down to 1e-8. The earlier 1e-5 floor (the
3517    // generic `BlockwiseFitOptions` default) was too LOOSE: the optimizer halted
3518    // in a low-curvature region with λ still well above its optimum, UNDER-fitting
3519    // the smooth-by-factor surface (truth-RMSE 0.164 vs VGAM's 0.061). So the
3520    // outer tolerance is floored at the calibrated REML tol — never tighter than
3521    // it (perf), never looser (accuracy) — while the caller's `tol` continues to
3522    // drive the INNER joint-Newton KKT target (`inner_tol` below), where its
3523    // precision actually matters.
3524    let outer_tol = if tol.is_finite() && tol > 0.0 {
3525        tol.max(MULTINOMIAL_OUTER_REML_TOL)
3526    } else {
3527        MULTINOMIAL_OUTER_REML_TOL
3528    };
3529    // #1082 root cause: the outer convergence test derives BOTH the absolute
3530    // projected-gradient floor (`max(outer_tol, n·1e-9)`) AND the relative-cost
3531    // stop (`rel_cost = outer_tol`) from the single `outer_tol`. The accuracy of
3532    // the smooth-by-factor surface is governed by the ABSOLUTE floor reaching the
3533    // n-scaled REML resolution `n·1e-9` (≈ 1.8e-6 at n = 1800) — that is why the
3534    // earlier 1e-5 floor UNDER-fit (its absolute floor was pinned at 1e-5, well
3535    // above the genuine optimum's gradient) and why 1e-7 recovered accuracy (it
3536    // unpins the floor down to the n-scaled 1.8e-6). But tightening `outer_tol`
3537    // to 1e-7 ALSO tightened the rel-cost stop to 1e-7, which on this family's
3538    // dead-flat REML ridge NEVER trips — so the optimizer no longer converges and
3539    // grinds all the way to `outer_max_iter`, each surplus step an O(D·p³) Laplace-
3540    // derivative assembly over the 382-dim joint design (the >600s wall-clock
3541    // overrun; tightening tol REINTRODUCED the crawl the 1e-5 floor had removed).
3542    //
3543    // The two requirements live on two different criteria, so they must be set
3544    // independently. Keep `outer_tol = 1e-7` (drives the accurate absolute floor)
3545    // but FLOOR the relative-cost stop at the framework default 1e-5 (the loose,
3546    // fast value that resolves the cost-decrease plateau without chasing the flat
3547    // tail). The absolute n·1e-9 floor still gates final λ accuracy; the rel-cost
3548    // stop just lets the optimizer DECLARE convergence on the flat ridge instead
3549    // of crawling to the iteration cap.
3550    let outer_rel_cost_tol = Some(BlockwiseFitOptions::default().outer_tol);
3551    let inner_tol = MULTINOMIAL_FORMULA_INNER_TOL.max(tol.max(0.0));
3552
3553    let options = BlockwiseFitOptions {
3554        inner_max_cycles: crate::custom_family::DEFAULT_CUSTOM_FAMILY_INNER_MAX_CYCLES,
3555        inner_tol,
3556        outer_max_iter,
3557        outer_tol,
3558        outer_rel_cost_tol,
3559        rho_lower_bound: multinomial_formula_min_lambda(y_one_hot.view()).ln(),
3560        ridge_floor: MULTINOMIAL_FORMULA_RIDGE_FLOOR,
3561        // #747: the stabilization floor is SOLVER-ONLY — it keeps the inner
3562        // joint-Newton linear solve finite during screening (bounding the step
3563        // `(H+δI)⁻¹∇` away from a near-separable, rank-deficient curvature) but
3564        // is excluded from the REML objective, the penalty log-determinant, and
3565        // the Laplace Hessian. The earlier default (`explicit_stabilization_pospart`)
3566        // folded `½·δ·‖β‖²` and a `δ`-shift of the log-determinant into the
3567        // criterion, shrinking every identified coefficient off the MLE and
3568        // perturbing smoothing-parameter selection — a fixed-λ prior masking
3569        // separation, not a numerical stabilizer. With the floor solver-only the
3570        // optimized objective is the true penalized REML criterion (value tracks
3571        // its analytic gradient), and the smooth directions remain governed
3572        // solely by their own REML-selected `λ`.
3573        ridge_policy: gam_problem::RidgePolicy::solver_only(),
3574        use_outer_hessian,
3575        // #715 real-data arm ("canonical-gauge null direction rejects all REML
3576        // seeds"): skip the multi-seed outer screening cascade and let the
3577        // pinned `init_lambda` ρ flow straight to the outer optimizer.
3578        //
3579        // The multinomial family declares `levenberg_on_ill_conditioning() ->
3580        // true`: near the simplex boundary (the near-separable penguins regime)
3581        // the softmax Fisher weight `W = diag(p) − p pᵀ → 0`, so the joint
3582        // information `H = JᵀWJ + S_λ` can become full-rank but
3583        // ILL-CONDITIONED. The self-vanishing LM damping that keeps the inner
3584        // joint-Newton from oscillating on those near-singular modes converges
3585        // only GEOMETRICALLY. The default screening policy ranks candidate seeds
3586        // with a 2-cycle inner cap (`outer_seed_config`); under geometric
3587        // LM-damped descent two cycles never reach a finite, meaningful proxy
3588        // objective, so EVERY capped seed can collapse to non-finite cost and
3589        // the cascade escalates to ×4, ×16, then an UNCAPPED full inner solve
3590        // PER SEED on the near-singular Hessian. That is the adapter-level face
3591        // of "all REML startup seeds rejected" and the multi-minute timeout.
3592        //
3593        // The pinned seed is already principled here: `init_lambda` gives every
3594        // (class, term) ρ a sensible moderate warm start, and the per-term
3595        // effective-df-floor upper bounds (`effective_df_floor_rho_upper_bounds`,
3596        // #715 arm (a)) keep any λ from collapsing the smooth onto its polynomial
3597        // null space. So the outer ARC/BFGS optimizer performs the real REML ρ
3598        // search from this seed; screening only adds the cascade cost and, on the
3599        // near-separable arm, the rejection stall.
3600        screen_initial_rho: false,
3601        // #1101: compute the joint Laplace posterior covariance `H⁻¹` (and the
3602        // influence matrix `F = H⁻¹ X'WX`) at the converged mode so the saved
3603        // model can surface delta-method per-class probability standard errors
3604        // and Wald smooth-term p-values. The driver factorizes the penalized
3605        // Hessian during the inner solve regardless; this only asks it to keep
3606        // and invert the factor instead of discarding it.
3607        compute_covariance: true,
3608        ..BlockwiseFitOptions::default()
3609    };
3610    Ok(PenalizedMultinomialFormulaParts {
3611        family,
3612        blocks,
3613        options,
3614        spec,
3615        design,
3616        class_levels,
3617        parametric_standardization,
3618        penalties_arc,
3619        raw_training_design,
3620        training_class_index,
3621        training_weights,
3622    })
3623}
3624
3625/// The coupled joint penalty `S_λ = Σ_s λ_s M_s` at the selected smoothing
3626/// parameters, in the stacked class-major coordinates the joint covariance, the
3627/// influence matrix and the posterior-mean predictive all share.
3628///
3629/// # Why the penalty is measured on its own (#2612)
3630///
3631/// `S_λ` used to be a by-product of the influence-matrix reconstruction
3632/// (`F = I − H⁻¹S_λ`), which needs two things the penalty does not:
3633///
3634/// * **at least one penalty component.** A wholly parametric multinomial —
3635///   `y ~ x1 + x2`, the shape both `quality_vs_statsmodels_multinomial` arms
3636///   fit — has none, so `S_λ` is the **zero operator**: a value, not an
3637///   absence. Reading it off a reconstruction that returns `None` on
3638///   `n_components == 0` turned "this model is unpenalized" into "this model
3639///   has no penalty operator", and the predictive then refused to publish any
3640///   parametric multinomial fit at all;
3641/// * **the joint posterior covariance `H⁻¹`.** That is a different measurement
3642///   of a different object. Conditioning the penalty's availability on it makes
3643///   a covariance failure surface as a missing penalty, naming the wrong one of
3644///   the two.
3645///
3646/// So the operator is assembled here from the family's own equivariant specs
3647/// and the selected `λ`, and every consumer reads THIS matrix — the influence
3648/// matrix and the published payload cannot describe different penalties.
3649///
3650/// # Basis
3651///
3652/// The specs live in the FITTED (standardized-parametric) basis, which is also
3653/// the basis of `training_design`'s penalized columns: the standardization
3654/// affine rescales parametric columns only and leaves every penalized column
3655/// identity-mapped, and `S_λ` is exactly zero outside the penalized columns. So
3656/// `A⁻ᵀS_λA⁻¹ = S_λ` and the published operator pairs with the raw design
3657/// without a change of variables. A penalty that ever reached a standardized
3658/// column would break that identity, which is why the zero-outside-penalized
3659/// structure is a property of the specs and not an assumption made here.
3660///
3661/// # Refusals
3662///
3663/// Only genuine inconsistencies: a selected-λ vector that does not match the
3664/// spec list, a spec of the wrong dimension, or λ reported for a model with
3665/// nothing to apply them to. Each of those is a disagreement between two parts
3666/// of the same fit, not a capability the fit lacks.
3667fn multinomial_joint_penalty_operator(
3668    joint_specs: &[gam_problem::JointPenaltySpec],
3669    joint_log_lambdas: Option<&Array1<f64>>,
3670    n_components: usize,
3671    expected_joint: usize,
3672) -> Result<Array2<f64>, EstimationError> {
3673    let mut s_lambda = Array2::<f64>::zeros((expected_joint, expected_joint));
3674    if n_components == 0 {
3675        let reported = joint_log_lambdas.map_or(0, |jll| jll.len());
3676        if reported != 0 || !joint_specs.is_empty() {
3677            crate::bail_invalid_estim!(
3678                "multinomial REML reported {reported} selected smoothing parameter(s) and {} \
3679                 coupled penalty spec(s) for a model carrying no penalty component; there is \
3680                 nothing for them to multiply",
3681                joint_specs.len(),
3682            );
3683        }
3684        // No penalized component exists, so the penalized log-posterior IS the
3685        // log-likelihood and the penalty is exactly zero.
3686        return Ok(s_lambda);
3687    }
3688    let jll = joint_log_lambdas.ok_or_else(|| {
3689        EstimationError::InvalidInput(format!(
3690            "multinomial REML converged with {n_components} penalty component(s) but surfaced no \
3691             selected joint smoothing parameters"
3692        ))
3693    })?;
3694    if jll.len() != joint_specs.len() || joint_specs.len() % n_components != 0 {
3695        crate::bail_invalid_estim!(
3696            "multinomial REML selected {} smoothing parameter(s) against {} coupled penalty \
3697             spec(s) over {n_components} component(s)",
3698            jll.len(),
3699            joint_specs.len(),
3700        );
3701    }
3702    for (spec, &log_lambda) in joint_specs.iter().zip(jll.iter()) {
3703        if spec.matrix.nrows() != expected_joint || spec.matrix.ncols() != expected_joint {
3704            crate::bail_invalid_estim!(
3705                "multinomial REML coupled penalty spec is {}x{}, expected \
3706                 {expected_joint}x{expected_joint}",
3707                spec.matrix.nrows(),
3708                spec.matrix.ncols(),
3709            );
3710        }
3711        s_lambda.scaled_add(log_lambda.exp(), &spec.matrix);
3712    }
3713    Ok(s_lambda)
3714}
3715
3716pub fn fit_penalized_multinomial_formula(
3717    request: &MultinomialFitRequest<'_>,
3718) -> Result<MultinomialSavedModel, EstimationError> {
3719    let PenalizedMultinomialFormulaParts {
3720        family,
3721        blocks,
3722        options,
3723        spec,
3724        design,
3725        class_levels,
3726        parametric_standardization,
3727        penalties_arc,
3728        raw_training_design,
3729        training_class_index,
3730        training_weights,
3731    } = penalized_multinomial_formula_parts(request)?;
3732    let MultinomialFitRequest {
3733        data,
3734        formula,
3735        config,
3736        ..
3737    } = *request;
3738    let m = family.active_classes();
3739    // ── Conditional Firth/Jeffreys engagement (#715 arm (b) / #753) ──────────
3740    // Attempt 1: the unbiased criterion (Jeffreys disarmed above). If the
3741    // returned mode is converged, finite, and interior, it is the exact penalized-REML
3742    // optimum with zero Firth bias — accept it (this is the synthetic-arm /
3743    // interior-data path, #715 arm (a)). If the solve FAILS (e.g. the
3744    // (quasi-)separated penguins geometry where `(H + S_λ)v ≈ 0` along
3745    // penalty-null directions for EVERY ρ rejects every REML startup seed) or
3746    // returns a non-finite artifact, that is direct separation evidence:
3747    // re-solve once with the full-span Jeffreys/Firth proper prior armed, which
3748    // supplies the O(1) curvature on the quotient-null subspace that smoothing
3749    // parameters mathematically cannot (`Sv = 0` ⇒ λ never touches `v`). The
3750    // Firth refit is the accepted result only when the unbiased formula solve
3751    // failed, did not converge on its full budget, or blew up; finite
3752    // formula-path logits can be large on valid near-separated optima and
3753    // should not be shrunk toward the uniform simplex once the unbiased outer
3754    // solve has actually certified.
3755    //
3756    // BOTH solves run on the caller's own `outer_max_iter` (#2612). The probe
3757    // used to run under `outer_max_iter.min(MULTINOMIAL_UNBIASED_PROBE_OUTER_MAX_ITER)`
3758    // — a 20-iteration ceiling introduced as a bare one-line `perf(#1082)` change
3759    // (`8d9a7b53b`, no test, no measurement) — while EVERY `Err` from it was
3760    // routed below as separation evidence. Those two decisions are not
3761    // independent: SPEC forbids minting a fit from an exhausted budget, so an
3762    // outer search stopped by that ceiling returns `RemlDidNotConverge`, and the
3763    // ceiling could therefore convert "this probe was still descending when I
3764    // stopped it" into "the data separate" — and from there into a proper prior
3765    // that shrinks the published probabilities toward the uniform simplex `1/K`.
3766    //
3767    // Nor was the ceiling saving work where it was honest. If the probe certifies
3768    // within its budget the ceiling is a no-op: the search stops on its own
3769    // certificate either way. The ONLY runs it shortened are the ones where it
3770    // stopped a still-descending search — exactly the runs whose verdict it
3771    // changed. Its entire benefit was co-extensive with the misdecision. SPEC:
3772    // "Wall-clock time budgets and deadlines are never allowed, except in tests.
3773    // In general, do not paper over solver issues."
3774    //
3775    // The FINAL accepted Firth/Jeffreys refit likewise runs to the caller's full
3776    // outer budget: it is the result we ship, so it must reach the genuine REML
3777    // optimum, not a truncated iterate. The near-separable penguin refit that
3778    // motivated #1082's wall-clock concern is halted honestly at its true bound
3779    // optimum by the KKT-stationary-at-bound guard (`CostStallGuard`, #1082 /
3780    // 64711ed82) and the Newton-decrement residual certificate (363af9b56 /
3781    // 2c9580b1f): on separable data the outer ARC certifies and stops early on
3782    // its own, so no artificial iteration cap is needed to land in budget. On
3783    // non-separable data (e.g. the `vgam_smooth_by_factor` double-penalty arm)
3784    // the refit needs the caller's full budget to converge, which a `.min(20)`
3785    // cap would cut off — accepting a non-converged fit, which is dishonest.
3786    let unbiased_probe_options = &options;
3787    let firth_refit_options = &options;
3788
3789    // The coupled penalty specs, built ONCE. They depend only on the family's
3790    // term structure, not on λ, so the same list serves the separation
3791    // certificate below (at the probe's selected λ) and the published `S_λ` at
3792    // the shipped fit's λ — the two cannot describe different penalty families.
3793    let n_penalty_components = penalties_arc.len();
3794    let joint_specs = if n_penalty_components == 0 {
3795        Vec::new()
3796    } else {
3797        family.equivariant_class_penalty_specs().map_err(|error| {
3798            EstimationError::InvalidInput(format!(
3799                "multinomial REML could not rebuild the coupled joint penalty: {error}"
3800            ))
3801        })?
3802    };
3803
3804    let run_firth_refit =
3805        |evidence: String,
3806         measured_span: Option<std::sync::Arc<Array2<f64>>>,
3807         warm_start: Option<(&[ParameterBlockState], Option<&Array1<f64>>)>| {
3808            // The span the certificate MEASURED travels into the refit with the
3809            // verdict it justified (#2612). `None` leaves the derived
3810            // `ker(S_lambda)` route in place, which is what a degenerate mode gets.
3811            let mut firth_family = family
3812                .clone()
3813                .with_joint_jeffreys_term(true)
3814                .with_joint_jeffreys_span(measured_span);
3815            let mut firth_blocks = blocks.clone();
3816            if let Some((states, log_lambdas)) = warm_start {
3817                if states.len() != firth_blocks.len() {
3818                    return Err(EstimationError::InvalidInput(format!(
3819                        "multinomial Firth warm start has {} coefficient blocks, expected {}",
3820                        states.len(),
3821                        firth_blocks.len()
3822                    )));
3823                }
3824                for (block, state) in firth_blocks.iter_mut().zip(states) {
3825                    if state.beta.len() != block.design.ncols() {
3826                        return Err(EstimationError::InvalidInput(format!(
3827                            "multinomial Firth warm-start block '{}' has {} coefficients, expected {}",
3828                            block.name,
3829                            state.beta.len(),
3830                            block.design.ncols()
3831                        )));
3832                    }
3833                    if state.beta.iter().any(|value| !value.is_finite()) {
3834                        return Err(EstimationError::InvalidInput(format!(
3835                            "multinomial Firth warm-start block '{}' contains non-finite coefficients",
3836                            block.name
3837                        )));
3838                    }
3839                    block.initial_beta = Some(state.beta.clone());
3840                }
3841                if let Some(log_lambdas) = log_lambdas {
3842                    if log_lambdas.iter().any(|value| !value.is_finite()) {
3843                        return Err(EstimationError::InvalidInput(
3844                            "multinomial Firth warm start contains non-finite log-lambdas"
3845                                .to_string(),
3846                        ));
3847                    }
3848                    firth_family =
3849                        firth_family.with_joint_initial_log_lambdas(log_lambdas.to_vec());
3850                }
3851            }
3852            log::info!(
3853                "multinomial REML: arming the Jeffreys/Firth proper prior — separation evidence: \
3854             {evidence}"
3855            );
3856            fit_custom_family_with_rho_prior(
3857                &firth_family,
3858                &firth_blocks,
3859                firth_refit_options,
3860                gam_problem::RhoPrior::Flat,
3861            )
3862            .map_err(|err| {
3863                EstimationError::InvalidInput(format!(
3864                    "multinomial REML: Firth/Jeffreys-armed refit (separation evidence: \
3865                 {evidence}) failed: {err}"
3866                ))
3867            })
3868        };
3869
3870    // The separation scans walk the full P×M logit block, so they are evaluated
3871    // ONCE into a binding and branched on, rather than recomputed in both a match
3872    // guard and its arm (three to four full logit walks per fit on the hot
3873    // near-separable path).
3874    let probe_attempt = fit_custom_family_with_rho_prior(
3875        &family,
3876        &blocks,
3877        unbiased_probe_options,
3878        gam_problem::RhoPrior::Flat,
3879    );
3880    // The evidence that armed the proper prior, or `None` when the unbiased
3881    // penalized-REML criterion was accepted. This is a FACT ABOUT THE FIT — the
3882    // two branches publish different estimands (the unbiased mode versus the
3883    // Firth-biased one) — so it is carried to the payload rather than living only
3884    // in a log line the caller never sees.
3885    let (fit, separation_evidence) = match probe_attempt {
3886        Ok(probe_fit) => {
3887            let identifiable_span = probe_fit
3888                .geometry
3889                .as_ref()
3890                .ok_or_else(|| {
3891                    EstimationError::InvalidInput(
3892                        "multinomial unbiased fit omitted its certified coefficient geometry"
3893                            .to_string(),
3894                    )
3895                })?
3896                .coefficient_gauge
3897                .t_full
3898                .view();
3899            // #2612: a model with NO penalty component selects no smoothing
3900            // parameter, so `joint_log_lambdas` is legitimately absent — the same
3901            // "no penalty is a value, not an absence" the joint-penalty operator
3902            // carries. Only a PENALIZED probe that reached a certified optimum
3903            // without surfacing its own selected λ is a broken fit, and only that
3904            // is refused. The certificate below needs those λ to form the
3905            // curvature the mode actually sits in, so the check comes first.
3906            let joint_log_lambdas = probe_fit.artifacts.joint_log_lambdas.as_ref();
3907            if joint_log_lambdas.is_none() && n_penalty_components != 0 {
3908                return Err(EstimationError::InvalidInput(format!(
3909                    "multinomial unbiased fit carries {n_penalty_components} penalty \
3910                     component(s) but omitted its converged joint log-lambdas"
3911                )));
3912            }
3913            let separation = multinomial_formula_penalized_separation_evidence(
3914                &family,
3915                &blocks,
3916                &probe_fit.block_states,
3917                identifiable_span,
3918                &joint_specs,
3919                n_penalty_components,
3920                joint_log_lambdas,
3921            )
3922            .map_err(|error| {
3923                EstimationError::InvalidInput(format!(
3924                    "multinomial REML separation certification failed: {error}"
3925                ))
3926            })?;
3927            match separation {
3928                None => {
3929                    // Fit existence proves both optimization layers certified; no
3930                    // post-hoc convergence flag is needed.
3931                    log::info!(
3932                        "multinomial REML: unbiased criterion accepted (no separation evidence; \
3933                         Jeffreys/Firth prior disarmed)"
3934                    );
3935                    (probe_fit, None)
3936                }
3937                Some(certificate) => {
3938                    let MultinomialSeparationCertificate {
3939                        evidence,
3940                        measured_span,
3941                    } = certificate;
3942                    // A certified unbiased optimum can still exhibit separation;
3943                    // use the already-computed mode and rho as the exact Firth
3944                    // continuation seed. The objectives differ only by the smooth
3945                    // Jeffreys term, so restarting from zero would discard the
3946                    // strongest available local information and repeat the whole
3947                    // unbiased path.
3948                    let refit = run_firth_refit(
3949                        evidence.clone(),
3950                        measured_span,
3951                        Some((&probe_fit.block_states, joint_log_lambdas)),
3952                    )?;
3953                    (refit, Some(evidence))
3954                }
3955            }
3956        }
3957        Err(err) => {
3958            let evidence = format!(
3959                "the unbiased criterion has no certified optimum on the caller's own outer \
3960                 budget ({} iteration(s)): {err}",
3961                options.outer_max_iter,
3962            );
3963            // No certified mode, so no measured span: the derived
3964            // `ker(S_lambda)` route stands.
3965            let refit = run_firth_refit(evidence.clone(), None, None)?;
3966            (refit, Some(evidence))
3967        }
3968    };
3969    if let Some(err) = multinomial_formula_separation_diagnostic(
3970        fit.inner_cycles,
3971        fit.outer_iterations,
3972        &fit.block_states,
3973    ) {
3974        return Err(err);
3975    }
3976
3977    // ── Repack coefficients (P, K-1) from per-block β vectors ─────────────
3978    if fit.blocks.len() != m {
3979        crate::bail_invalid_estim!(
3980            "multinomial REML: expected {m} fitted blocks (K-1), got {}",
3981            fit.blocks.len()
3982        );
3983    }
3984    let p_per_class = fit.blocks[0].beta.len();
3985    let mut coefficients_active = Array2::<f64>::zeros((p_per_class, m));
3986    for (a, block) in fit.blocks.iter().enumerate() {
3987        if block.beta.len() != p_per_class {
3988            crate::bail_invalid_estim!(
3989                "multinomial REML: block {a} has {} coefs, expected {p_per_class}",
3990                block.beta.len()
3991            );
3992        }
3993        for i in 0..p_per_class {
3994            coefficients_active[[i, a]] = block.beta[i];
3995        }
3996    }
3997    // Map the standardized-column coefficients back to raw units (the exact
3998    // inverse of the conditioning reparameterization above): β_raw = b/s, with
3999    // the centering mass `Σ_j b_j·m_j/s_j` returned to the intercept.
4000    if !parametric_standardization.is_empty() {
4001        let intercept_col = design.intercept_range.clone().next();
4002        for a in 0..m {
4003            let mut intercept_adjust = 0.0;
4004            for &(col, center, scale) in &parametric_standardization {
4005                if col < p_per_class {
4006                    let raw = coefficients_active[[col, a]] / scale;
4007                    coefficients_active[[col, a]] = raw;
4008                    intercept_adjust += raw * center;
4009                }
4010            }
4011            if let Some(i0) = intercept_col
4012                && i0 < p_per_class
4013            {
4014                coefficients_active[[i0, a]] -= intercept_adjust;
4015            }
4016        }
4017    }
4018    // Flatten every (class, term) smoothing parameter in block-major order
4019    // (class 0's terms, then class 1's, …). With per-term penalties each block
4020    // now carries one λ per smooth term, so a single λ per class would discard
4021    // the independent per-term selection that fixes #561. `lambdas_per_block`
4022    // segments the flat vector by class so callers can recover per-term λ.
4023    let expected_joint = p_per_class.checked_mul(m).ok_or_else(|| {
4024        EstimationError::InvalidInput(
4025            "multinomial posterior covariance dimension overflowed usize".to_string(),
4026        )
4027    })?;
4028    // ── The penalty operator, measured once (#2612) ──────────────────────────
4029    // `S_λ` is a fact about the fit in its own right: the influence-matrix
4030    // reconstruction below and the published predictive payload both read THIS
4031    // matrix, so they cannot describe different penalties, and a model with no
4032    // penalized term publishes the zero operator it has instead of being refused
4033    // for not having one. See `multinomial_joint_penalty_operator`.
4034    //
4035    // `joint_specs` was built once above the probe (it is λ-independent) and is
4036    // the same list the separation certificate formed `H + S_λ` from, so the
4037    // arming decision, the influence matrix and the published payload all read
4038    // one penalty family. Under the equivariant carrier this is K per-class specs
4039    // per term, grouped term-major (`s = t·K + c`); the K = 2 degenerate arm
4040    // returns one shared centered spec per term.
4041    let joint_penalty = multinomial_joint_penalty_operator(
4042        &joint_specs,
4043        fit.artifacts.joint_log_lambdas.as_ref(),
4044        n_penalty_components,
4045        expected_joint,
4046    )?;
4047    // ── gam#1587/#561 joint-penalty reconstruction ───────────────────────────
4048    // Under the #1587 centered-metric architecture every active class block
4049    // leaves its per-block penalty list EMPTY — the entire fit's smoothing rides
4050    // on a single full-width JOINT penalty `S_λ = Σ_t λ_t (M ⊗ S_t)` whose one
4051    // shared `λ_t` per smooth component is selected by the outer REML loop and
4052    // surfaced on `fit.artifacts.joint_log_lambdas`. So `fit.blocks[a].lambdas`
4053    // is `[]`, the inference layer's per-block trace channel is empty, and the
4054    // older per-block reporting (`lambdas_per_block = [0, 0]`, `edf_per_class =
4055    // None`, …) collapsed (#561 reopen).
4056    //
4057    // Reconstruct the per-(class, component) λ and the influence-matrix EDF
4058    // directly from the selected joint `λ_t` and the COUPLED penalty
4059    // `S_λ = Σ_t λ_t (M ⊗ S_t)` (NOT a block-diagonal `Σ_t λ_{a,t} S_t`: the
4060    // centered metric `M` couples classes off the block diagonal, so a
4061    // block-diagonal `S_λ` would mis-state both the influence matrix and every
4062    // trace). With `H⁻¹ = fit.covariance_conditional` now assembled WITH the
4063    // joint penalty (the `compute_joint_covariance` fix), the influence matrix is
4064    // exactly `F = I − H⁻¹ S_λ`, its per-class diagonal-block trace is the honest
4065    // per-class EDF, and `Σ_a edf_a = tr(F) = edf_total`.
4066    let joint_recon = fit.artifacts.joint_log_lambdas.as_ref().and_then(|jll| {
4067        let n_components = n_penalty_components;
4068        if n_components == 0 {
4069            return None;
4070        }
4071        // `joint_specs` is the list `joint_penalty` was assembled from, so the
4072        // influence matrix and the published operator cannot describe different
4073        // penalties.
4074        if jll.len() != joint_specs.len() || joint_specs.len() % n_components != 0 {
4075            return None;
4076        }
4077        let specs_per_term = joint_specs.len() / n_components;
4078        let hinv = fit
4079            .covariance_conditional
4080            .as_ref()
4081            .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)?;
4082        let lam: Vec<f64> = jll.iter().map(|&l| l.exp()).collect();
4083        // Per-spec `H⁻¹ M_s` (full mp×mp), reused for both the joint influence
4084        // matrix and the per-(class, component) trace decomposition.
4085        let mut hinv_st: Vec<Array2<f64>> = Vec::with_capacity(joint_specs.len());
4086        for spec in &joint_specs {
4087            if spec.matrix.nrows() != expected_joint || spec.matrix.ncols() != expected_joint {
4088                return None;
4089            }
4090            hinv_st.push(hinv.dot(&spec.matrix));
4091        }
4092        // F = I − H⁻¹ S_λ = I − Σ_s λ_s H⁻¹ M_s.
4093        let mut f = Array2::<f64>::eye(expected_joint);
4094        for (s, hs) in hinv_st.iter().enumerate() {
4095            f.scaled_add(-lam[s], hs);
4096        }
4097        // Per-class diagonal-block trace of F (the honest per-class EDF), and
4098        // the per-(class, component) penalty trace
4099        // `tr_{a,t} = Σ_{c∈term t} λ_{t,c} · Σ_{i∈class a} (H⁻¹ M_{t,c})[i,i]`
4100        // for the per-penalty EDF rollup.
4101        let mut edf_per_class = Vec::with_capacity(m);
4102        // class-major per-penalty EDF (class 0's components, then class 1's, …),
4103        // aligned 1:1 with the flat per-(class, component) λ report below.
4104        let mut edf_per_penalty = Vec::with_capacity(m * n_components);
4105        for a in 0..m {
4106            let base = a * p_per_class;
4107            let mut class_trace = 0.0_f64;
4108            for t in 0..n_components {
4109                let mut tr_at = 0.0_f64;
4110                for c in 0..specs_per_term {
4111                    let s = t * specs_per_term + c;
4112                    let mut tr = 0.0_f64;
4113                    for i in 0..p_per_class {
4114                        tr += hinv_st[s][[base + i, base + i]];
4115                    }
4116                    tr_at += lam[s] * tr;
4117                }
4118                class_trace += tr_at;
4119                // A single component's per-class trace EDF `rank(S_t) − tr_{a,t}`,
4120                // bounded by its local rank (≤ p_per_class). Derive rank(S_t)
4121                // from the spec's MEASURED nullity (per-class spec: rank =
4122                // m·p − nullspace_dim; shared centered spec: m·rank), so the
4123                // reporting rank matches the pseudo-logdet rank exactly.
4124                let spec0 = &joint_specs[t * specs_per_term];
4125                let joint_rank = expected_joint - spec0.nullspace_dim;
4126                let rank_t = if specs_per_term > 1 {
4127                    joint_rank as f64
4128                } else {
4129                    (joint_rank as f64) / (m as f64)
4130                };
4131                edf_per_penalty.push((rank_t - tr_at).clamp(0.0, p_per_class as f64));
4132            }
4133            edf_per_class.push((p_per_class as f64 - class_trace).clamp(0.0, p_per_class as f64));
4134        }
4135        // Per-(class, component) λ report, class-major. Under the equivariant
4136        // carrier the smoothing applied to active class `a`'s centered function
4137        // for term `t` is its own `λ_{t,c=a}` (spec index `t·K + a`); under the
4138        // K = 2 shared arm every class reports the one `λ_t`.
4139        let mut lam_flat = Vec::with_capacity(m * n_components);
4140        for a in 0..m {
4141            for t in 0..n_components {
4142                let s = if specs_per_term > 1 {
4143                    t * specs_per_term + a
4144                } else {
4145                    t
4146                };
4147                lam_flat.push(lam[s]);
4148            }
4149        }
4150        Some((f, edf_per_class, edf_per_penalty, n_components, lam_flat))
4151    });
4152
4153    // Flatten every (class, component) smoothing parameter in class-major order.
4154    // Under the equivariant joint-penalty architecture each active class `a`
4155    // reports its own `λ_{t,a}` per term (the per-class centered penalties;
4156    // the K = 2 degenerate arm replicates the shared `λ_t`), so the flat vector
4157    // is class-major with `lambdas_per_block = [n_components; K-1]`. When the
4158    // joint reconstruction is unavailable (legacy fixed-λ path or absent
4159    // covariance) fall back to the raw — now empty — per-block λ lists.
4160    let (lambdas_per_block, lambdas_flat): (Vec<usize>, Vec<f64>) = match joint_recon.as_ref() {
4161        Some((_, _, _, n_components, lam_flat)) => {
4162            let per_block = vec![*n_components; m];
4163            (per_block, lam_flat.clone())
4164        }
4165        None => {
4166            let per_block: Vec<usize> = fit.blocks.iter().map(|b| b.lambdas.len()).collect();
4167            let flat: Vec<f64> = fit
4168                .blocks
4169                .iter()
4170                .flat_map(|b| b.lambdas.iter().copied())
4171                .collect();
4172            (per_block, flat)
4173        }
4174    };
4175    // Per-active-class effective degrees of freedom, length `K-1`, summing to
4176    // the model `edf_total`. The REML inference block reports `edf_by_block` as
4177    // ONE entry per *penalty block* (per (class, term, penalty)), each computed
4178    // as `rank(S_kk) − tr(H⁻¹ λ_kk S_kk)`. That per-block sum OVER-COUNTS the
4179    // model EDF whenever several penalties share one coefficient range — a
4180    // double-penalty / te / ti / adaptive smooth has ≥2 penalty blocks over the
4181    // same columns, so `Σ_kk rank(S_kk) > p` and `Σ_kk edf_by_block > edf_total`
4182    // (the observed ~79 for a ~24-coefficient model). Handing that raw per-block
4183    // vector out as the documented length-(K-1) per-class EDF is therefore both
4184    // the wrong LENGTH (it is `Σ_a n_blocks_a`, not `K-1`) and an over-count.
4185    //
4186    // The honest per-class EDF is the influence-matrix trace over each class's
4187    // coefficient block. Classes occupy DISJOINT `p_per_class`-wide coefficient
4188    // ranges, and the per-block traces `tr_kk = tr(H⁻¹ λ_kk S_kk)` are additive
4189    // (no rank double-counting), so class `a`'s EDF is
4190    // `p_per_class − Σ_{kk ∈ class a} tr_kk`, and `Σ_a edf_a = m·p_per_class −
4191    // Σ_kk tr_kk = p − Σ tr_kk = edf_total` exactly. Segment the block-major
4192    // `penalty_block_trace` by `lambdas_per_block` (the same per-class λ-count
4193    // segmentation `lambdas_flat` uses). Fall back to `None` when the trace
4194    // channel is unavailable or mis-shaped (legacy fixed-λ path), exactly as the
4195    // raw `edf_by_block` map did before.
4196    let edf_per_class = joint_recon
4197        .as_ref()
4198        .map(|(_, epc, _, _, _)| epc.clone())
4199        .or_else(|| {
4200            // Legacy per-block trace path (fixed-λ / pre-#1587 fits whose
4201            // smoothing is still carried per block). Segment the block-major
4202            // `penalty_block_trace` by `lambdas_per_block`, exactly as before.
4203            fit.inference.as_ref().and_then(|info| {
4204                let traces = &info.penalty_block_trace;
4205                if traces.len() != lambdas_per_block.iter().sum::<usize>() {
4206                    return None;
4207                }
4208                let mut per_class = Vec::with_capacity(m);
4209                let mut cursor = 0usize;
4210                for &n_blocks in &lambdas_per_block {
4211                    let class_trace: f64 = traces[cursor..cursor + n_blocks].iter().sum();
4212                    per_class
4213                        .push((p_per_class as f64 - class_trace).clamp(0.0, p_per_class as f64));
4214                    cursor += n_blocks;
4215                }
4216                Some(per_class)
4217            })
4218        });
4219    // Per-PENALTY EDF: the inference layer's `edf_by_block` is already the
4220    // clamped per-penalty-block trace EDF `rank(S_k) − λ_k·tr(H⁻¹ S_k)`, one
4221    // entry per smoothing parameter and block-major aligned 1:1 with the flat
4222    // `lambdas`. Surface it verbatim (guarding only on the length contract) so
4223    // consumers can inspect per-(class, term, penalty) collapse onto the null
4224    // space — a signal the per-class EDF SUM hides. This is NOT a per-class
4225    // total: with double-penalty smooths `Σ_k rank(S_k) > p_per_class`, so the
4226    // entries deliberately need not sum to the model EDF (the per-class field
4227    // carries that contract instead).
4228    let edf_per_penalty = joint_recon
4229        .as_ref()
4230        .map(|(_, _, epp, _, _)| epp.clone())
4231        .or_else(|| {
4232            // Legacy per-block path: the inference layer's `edf_by_block` is
4233            // already the clamped per-penalty-block trace EDF, aligned 1:1 with
4234            // the flat `lambdas`.
4235            fit.inference.as_ref().and_then(|info| {
4236                if info.edf_by_block.len() != lambdas_flat.len() {
4237                    return None;
4238                }
4239                Some(
4240                    info.edf_by_block
4241                        .iter()
4242                        .map(|&e| e.max(0.0))
4243                        .collect::<Vec<f64>>(),
4244                )
4245            })
4246        });
4247    let coefficients_flat: Vec<f64> = coefficients_active.iter().copied().collect();
4248
4249    // #1101: surface the joint Laplace posterior covariance `H⁻¹` (block-ordered
4250    // [β_0; …; β_{K-2}]) and the influence matrix `F = H⁻¹ X'WX` the REML driver
4251    // computed at the converged mode. These power the predict path's delta-method
4252    // per-class probability standard errors and the summary's Wald smooth-term
4253    // tests. The joint matrices are `(P·M)×(P·M)`. The covariance is mapped back
4254    // to RAW units (see below) so it pairs with the raw predict design; the
4255    // influence is kept in the fitted basis (the Wald table only slices penalized
4256    // columns, which the standardization affine leaves identity-mapped).
4257    // The joint Hessian (and thus `H⁻¹`) was assembled in the STANDARDIZED
4258    // parametric basis used during fitting, while the saved coefficients and the
4259    // raw predict design are in raw units. Map the covariance to raw units with
4260    // the same exact affine reparameterization `β_raw = A β_std`: for each
4261    // standardized parametric column `col`, `β_raw[col] = β_std[col]/scale` and
4262    // the intercept absorbs `−Σ_col (center/scale)·β_std[col]`. So `A = I` except
4263    // `A[col,col] = 1/scale` and `A[i0,col] = −center/scale`, replicated
4264    // block-diagonally per active class, and `Cov_raw = A Cov_std Aᵀ`. With no
4265    // standardization (`parametric_standardization` empty) `A = I` and this is a
4266    // no-op. The smooth-term (penalized) columns are untouched by `A`, so the
4267    // Wald table's per-term blocks are identical in both bases.
4268    let intercept_col0 = design.intercept_range.clone().next();
4269    let build_per_class_affine = |amat: &mut Array2<f64>| {
4270        for &(col, center, scale) in &parametric_standardization {
4271            if col >= p_per_class {
4272                continue;
4273            }
4274            amat[[col, col]] = 1.0 / scale;
4275            if let Some(i0) = intercept_col0
4276                && i0 < p_per_class
4277            {
4278                amat[[i0, col]] = -center / scale;
4279            }
4280        }
4281    };
4282    // One raw-unit map, used by BOTH joint covariance-frame matrices. `C` lives
4283    // in the same lifted frame as `V_cond` (it is assembled against it — see
4284    // `BlockwiseFitAssembly::smoothing_corrected`), so it must take the same
4285    // congruence; mapping only one of the pair would publish `V_cond + C` in two
4286    // different parameterizations.
4287    let to_raw_units = |matrix_std: &Array2<f64>| -> Vec<f64> {
4288        if parametric_standardization.is_empty() {
4289            return matrix_std.iter().copied().collect::<Vec<f64>>();
4290        }
4291        // Block-diagonal joint A (same per active class).
4292        let mut a_joint = Array2::<f64>::eye(expected_joint);
4293        let mut a_class = Array2::<f64>::eye(p_per_class);
4294        build_per_class_affine(&mut a_class);
4295        for a in 0..m {
4296            let base = a * p_per_class;
4297            for i in 0..p_per_class {
4298                for j in 0..p_per_class {
4299                    a_joint[[base + i, base + j]] = a_class[[i, j]];
4300                }
4301            }
4302        }
4303        let raw = a_joint.dot(matrix_std).dot(&a_joint.t());
4304        raw.iter().copied().collect::<Vec<f64>>()
4305    };
4306    let coefficient_covariance_flat = fit
4307        .covariance_conditional
4308        .as_ref()
4309        .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)
4310        .map(|cov_std| to_raw_units(cov_std))
4311        .ok_or_else(|| {
4312            EstimationError::InvalidInput(format!(
4313                "multinomial REML converged without the required {expected_joint}x{expected_joint} joint posterior covariance"
4314            ))
4315        })?;
4316    // gam#2612: the same fit already computed the first-order ρ-uncertainty
4317    // correction `C = J·Var(ρ̂)·Jᵀ` (#2346) and published it on the inference
4318    // block; every other family in the library carries it onto its predict
4319    // surface and this one dropped it at the read boundary, which is what made
4320    // every multinomial band conditional-on-λ̂ and the standing coverage gate
4321    // for the mean-probability band anti-conservative. A mis-shaped correction
4322    // is a typed absence rather than an error: the conditional definition is
4323    // still a correct answer to a narrower question, and the consumers say which
4324    // one they used.
4325    let smoothing_correction_flat = fit.inference.as_ref().and_then(|info| {
4326        info.smoothing_correction
4327            .as_ref()
4328            .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)
4329            .map(|c| to_raw_units(c))
4330    });
4331    // The influence matrix `F = H⁻¹ X'WX = H⁻¹(H − S_λ) = I − H⁻¹ S_λ`. The
4332    // exact-Newton multinomial blocks carry no IRLS pseudo-data, so the generic
4333    // inference path does not export `coefficient_influence`; reconstruct it
4334    // exactly here. Under the #1587 joint-penalty architecture the penalty is the
4335    // COUPLED centered metric `S_λ = Σ_t λ_t (M ⊗ S_t)` (off the class-block
4336    // diagonal), already assembled in `joint_recon` above, so reuse that exact
4337    // `F`. Only fall back to the legacy block-diagonal `Σ_t λ_{a,t} S_t`
4338    // reconstruction when the joint reconstruction is unavailable (pre-#1587
4339    // per-block fits whose class blocks still carry their own penalties).
4340    let coefficient_influence_flat = match joint_recon.as_ref() {
4341        Some((f, _, _, _, _)) => Some(f.iter().copied().collect::<Vec<f64>>()),
4342        None => fit
4343            .covariance_conditional
4344            .as_ref()
4345            .filter(|c| c.nrows() == expected_joint && c.ncols() == expected_joint)
4346            .and_then(|hinv| {
4347                if fit.blocks.len() != m {
4348                    return None;
4349                }
4350                // Joint S_λ (block-diagonal across active classes).
4351                let mut s_lambda = Array2::<f64>::zeros((expected_joint, expected_joint));
4352                for (a, block) in fit.blocks.iter().enumerate() {
4353                    if block.lambdas.len() != penalties_arc.len() {
4354                        return None;
4355                    }
4356                    let base = a * p_per_class;
4357                    for (t, pen) in penalties_arc.iter().enumerate() {
4358                        let lam = block.lambdas[t];
4359                        if lam == 0.0 {
4360                            continue;
4361                        }
4362                        let dense = pen.to_dense();
4363                        if dense.nrows() != p_per_class || dense.ncols() != p_per_class {
4364                            return None;
4365                        }
4366                        for i in 0..p_per_class {
4367                            for j in 0..p_per_class {
4368                                s_lambda[[base + i, base + j]] += lam * dense[[i, j]];
4369                            }
4370                        }
4371                    }
4372                }
4373                // F = I − H⁻¹ S_λ.
4374                let hinv_s = hinv.dot(&s_lambda);
4375                let mut f = Array2::<f64>::eye(expected_joint);
4376                f -= &hinv_s;
4377                Some(f.iter().copied().collect::<Vec<f64>>())
4378            }),
4379    };
4380
4381    // Per-(smooth term) coefficient span within a single class block, deduped by
4382    // col_range (the #561 double-penalty migration emits two penalty blocks per
4383    // term sharing one col_range; the Wald test covers the whole term block once).
4384    let mut smooth_term_spans: Vec<MultinomialSmoothTermSpan> = Vec::new();
4385    for (pen_idx, bp) in design.penalties.iter().enumerate() {
4386        let col_start = bp.col_range.start;
4387        let col_end = bp.col_range.end;
4388        if col_start >= col_end || col_end > p_per_class {
4389            continue;
4390        }
4391        if smooth_term_spans
4392            .iter()
4393            .any(|s| s.col_start == col_start && s.col_end == col_end)
4394        {
4395            continue;
4396        }
4397        let label = design
4398            .penaltyinfo
4399            .get(pen_idx)
4400            .and_then(|info| info.termname.clone())
4401            .unwrap_or_else(|| format!("s{pen_idx}"));
4402        let nullspace_dim = design
4403            .nullspace_dims
4404            .get(pen_idx)
4405            .copied()
4406            .unwrap_or(0)
4407            .min(col_end - col_start);
4408        smooth_term_spans.push(MultinomialSmoothTermSpan {
4409            label,
4410            col_start,
4411            col_end,
4412            nullspace_dim,
4413        });
4414    }
4415
4416    // One descriptive label per penalty *component* within a single class block,
4417    // parallel to that block's λ slice (#1544). `design.penalties` is index-
4418    // parallel to every active class's `block.lambdas` (each block carries the
4419    // full per-component penalty list, validated above by
4420    // `block.lambdas.len() == penalties_arc.len()`), so iterating it in order
4421    // yields exactly `lambdas_per_block[0]` labels aligned with the per-block λ.
4422    // This is deliberately NOT deduped by col_range (unlike `smooth_term_spans`):
4423    // the double penalty's primary and null-space components share one col_range
4424    // but select independent λ, and each must keep its own label so the summary
4425    // renderer never collapses or drops a λ.
4426    let lambda_labels: Vec<String> = design
4427        .penalties
4428        .iter()
4429        .enumerate()
4430        .map(|(pen_idx, _)| penalty_component_label(design.penaltyinfo.get(pen_idx), pen_idx))
4431        .collect();
4432
4433    // Unpenalized deviance read directly from the converged unpenalized
4434    // log-likelihood the rho-prior driver already computed (issue #348):
4435    // MultinomialFamily::evaluate sets FamilyEvaluation.log_likelihood =
4436    // log_lik(η, y) with no penalty term, and that value flows unchanged into
4437    // UnifiedFitResult.log_likelihood. This reproduces the legacy fixed-λ
4438    // path's `deviance = -2 · log_lik` contract bit-for-bit, so the previous
4439    // row-by-row η = Xβ rebuild and softmax recompute were pure dead work.
4440    let deviance = -2.0 * fit.log_likelihood;
4441
4442    // #2612: the training frame and the penalty operator the posterior-mean
4443    // predictive needs. `joint_penalty` was measured above from the family's own
4444    // equivariant specs and the selected λ — the same matrix the influence
4445    // reconstruction consumed — so the payload and `F` cannot describe different
4446    // penalties, and an unpenalized model publishes the zero operator it has
4447    // rather than being refused for not having one.
4448    let joint_penalty_flat: Vec<f64> = joint_penalty.iter().copied().collect();
4449    if raw_training_design.ncols() != p_per_class {
4450        crate::bail_invalid_estim!(
4451            "multinomial REML: raw training design has {} columns but the fit reports \
4452             p_per_class = {p_per_class}",
4453            raw_training_design.ncols(),
4454        );
4455    }
4456    let training_rows = raw_training_design.nrows();
4457    let training_design_flat: Vec<f64> = raw_training_design.iter().copied().collect();
4458    let training_weights_flat: Vec<f64> = training_weights.iter().copied().collect();
4459
4460    Ok(MultinomialSavedModel {
4461        formula: formula.to_string(),
4462        class_levels: class_levels.clone(),
4463        reference_class_index: class_levels.len() - 1,
4464        resolved_termspec: spec,
4465        coefficients_flat,
4466        p_per_class,
4467        n_active_classes: m,
4468        training_headers: data.headers.clone(),
4469        training_table_kind: config.training_table_kind.clone(),
4470        lambdas: lambdas_flat,
4471        lambdas_per_block,
4472        iterations: fit.inner_cycles,
4473        separation_evidence,
4474        penalized_neg_log_likelihood: -fit.log_likelihood + 0.5 * fit.stable_penalty_term,
4475        deviance,
4476        edf_per_class,
4477        edf_per_penalty,
4478        coefficient_covariance_flat,
4479        smoothing_correction_flat,
4480        coefficient_influence_flat,
4481        smooth_term_spans,
4482        training_design_flat,
4483        training_rows,
4484        training_class_index,
4485        training_weights: training_weights_flat,
4486        joint_penalty_flat,
4487        lambda_labels,
4488    })
4489}
4490
4491/// Replay the saved termspec to build the predict-time dense design `X` on a
4492/// fresh dataset, realigning feature columns **by name** so the predict frame
4493/// need not reproduce the training column order or carry the response column.
4494/// Shared by every multinomial predict path (probabilities, SE bands, and the
4495/// posterior-predictive replicate draws).
4496fn build_multinomial_predict_design(
4497    model: &MultinomialSavedModel,
4498    data: &EncodedDataset,
4499) -> Result<Array2<f64>, EstimationError> {
4500    // The saved termspec stores feature columns as absolute indices into the
4501    // *training* table `[response, features...]`. Realign them onto this
4502    // dataset's columns by name, so prediction works on label-free new data
4503    // (the response column is never referenced by any term; issue #803).
4504    let predict_columns = data.column_map();
4505    let realigned = model.resolved_termspec.remap_feature_columns(
4506        |index| -> Result<usize, EstimationError> {
4507            let name = model.training_headers.get(index).ok_or_else(|| {
4508                EstimationError::InvalidInput(format!(
4509                    "multinomial predict: saved training column index {index} is out of bounds \
4510                     for {} training headers",
4511                    model.training_headers.len()
4512                ))
4513            })?;
4514            resolve_role_col(&predict_columns, name, "feature")
4515                .map_err(|err| EstimationError::InvalidInput(err.to_string()))
4516        },
4517    )?;
4518    let design = build_term_collection_design(data.values.view(), &realigned).map_err(|err| {
4519        EstimationError::InvalidInput(format!(
4520            "multinomial predict: rebuild design from saved termspec: {err}"
4521        ))
4522    })?;
4523    if design.affine_offset.iter().any(|value| *value != 0.0) {
4524        crate::bail_invalid_estim!(
4525            "multinomial predict does not support non-zero smooth anchors: the saved \
4526             reference-coded softmax has no per-class affine offset channel"
4527        );
4528    }
4529    let x_dense = design
4530        .design
4531        .try_to_dense_by_chunks("multinomial predict design")
4532        .map_err(EstimationError::InvalidInput)?;
4533    if x_dense.ncols() != model.p_per_class {
4534        crate::bail_invalid_estim!(
4535            "multinomial predict: predict design has {} cols, saved model expects {}",
4536            x_dense.ncols(),
4537            model.p_per_class
4538        );
4539    }
4540    Ok(x_dense)
4541}
4542
4543/// Replay the saved termspec to build the predict-time design on a fresh
4544/// dataset, then evaluate the POSTERIOR-MEAN class probabilities
4545/// `E[softmax(x'β) | data]`. The predict dataset must carry the same feature
4546/// columns the training data did, matched **by name** — it need not reproduce
4547/// the training column order, and in particular need not carry the response
4548/// column (prediction is for label-free new data).
4549///
4550/// The posterior mean is computed as a ratio of normalising constants rather
4551/// than by integrating `softmax` over the Laplace Gaussian; see
4552/// [`crate::multinomial_predictive`] for why the latter is not an approximation
4553/// of this estimand (#2612). For the plug-in `softmax(x'β̂)` every other softmax
4554/// implementation reports, ask [`predict_multinomial_formula_plugin`] by name.
4555pub fn predict_multinomial_formula(
4556    model: &MultinomialSavedModel,
4557    data: &EncodedDataset,
4558) -> Result<Array2<f64>, EstimationError> {
4559    model.validate()?;
4560    let x_dense = build_multinomial_predict_design(model, data)?;
4561    model.predict_probabilities(x_dense.view())
4562}
4563
4564/// Plug-in class probabilities `softmax(x'β̂)` at the posterior MODE, for a
4565/// saved multinomial model on fresh data.
4566///
4567/// [`predict_multinomial_formula`] returns a different estimand: the
4568/// posterior-mean probability `E[softmax(η)]`. Both are legitimate and they are
4569/// not interchangeable, but the difference between them is much smaller than it
4570/// used to appear, and the reason is worth stating here because this function is
4571/// where a reader compares the two.
4572///
4573/// `softmax` is concave along the winning coordinate, so averaging it over
4574/// posterior width pulls the answer toward the centre of the simplex. The
4575/// posterior of a logit is also right-skewed toward larger `|η|`, which pulls
4576/// the other way. A correct posterior mean carries BOTH; the Gaussian
4577/// integration this path used to perform carried only the first, which is why
4578/// the published probability read as under-confident at unchanged argmax
4579/// (#2612). Measured on a quasi-separated fixture against an MCMC posterior, the
4580/// Gaussian-integrated quantity is off by up to `2.1e-1` in probability while
4581/// the ratio estimator [`predict_multinomial_formula`] now uses is off by
4582/// `4.4e-3` — and the plug-in below sits much closer to the posterior mean than
4583/// the Gaussian did.
4584///
4585/// `nnet::multinom`, `scikit-learn`, `statsmodels` and every other softmax
4586/// reference report the plug-in quantity, so a held-out log-loss comparison
4587/// against any of them is a comparison of two estimands unless this function is
4588/// the one supplying gam's side.
4589///
4590/// This is deliberately NOT a fallback: the posterior-mean path refuses rather
4591/// than degrading to a plug-in when it cannot certify its own accuracy, and that
4592/// refusal stands. A caller who wants the mode's own probability has to ask for
4593/// it here, by name.
4594pub fn predict_multinomial_formula_plugin(
4595    model: &MultinomialSavedModel,
4596    data: &EncodedDataset,
4597) -> Result<Array2<f64>, EstimationError> {
4598    model.validate()?;
4599    let x_dense = build_multinomial_predict_design(model, data)?;
4600    let coefficients = model.coefficients_active()?;
4601    let eta = x_dense.dot(&coefficients);
4602    let n_rows = eta.nrows();
4603    let n_classes = model.n_active_classes + 1;
4604    let mut probabilities = Array2::<f64>::zeros((n_rows, n_classes));
4605    for (row, mut destination) in eta.rows().into_iter().zip(probabilities.rows_mut()) {
4606        let active: Vec<f64> = row.iter().copied().collect();
4607        let row_probabilities = softmax_with_reference(&active)?;
4608        for (class, probability) in row_probabilities.iter().enumerate() {
4609            destination[class] = *probability;
4610        }
4611    }
4612    Ok(probabilities)
4613}
4614
4615/// Draw `n_draws` posterior-predictive replicate class-label assignments for a
4616/// saved multinomial model on fresh data (#1101). Rebuilds the predict design
4617/// exactly as [`predict_multinomial_formula`], then samples each row's class
4618/// from `Categorical(E[softmax(η) | data])` (see
4619/// [`MultinomialSavedModel::sample_replicate_classes`]). Returns an
4620/// `(n_draws, N)` matrix of class INDICES `0..K` aligned to `model.class_levels`,
4621/// deterministic in `seed`.
4622pub fn posterior_predict_multinomial_formula(
4623    model: &MultinomialSavedModel,
4624    data: &EncodedDataset,
4625    n_draws: usize,
4626    seed: u64,
4627) -> Result<Array2<u32>, EstimationError> {
4628    if n_draws == 0 {
4629        crate::bail_invalid_estim!("multinomial posterior_predict: n_draws must be >= 1");
4630    }
4631    model.validate()?;
4632    let x_dense = build_multinomial_predict_design(model, data)?;
4633    model.sample_replicate_classes(x_dense.view(), n_draws, seed)
4634}
4635
4636/// Predict posterior-mean class probabilities and integrated marginal
4637/// standard deviations for a saved multinomial model on fresh data, under the
4638/// best covariance definition the model can support (see
4639/// [`MultinomialSavedModel::predict_probabilities_with_se`]).
4640pub fn predict_multinomial_formula_with_se(
4641    model: &MultinomialSavedModel,
4642    data: &EncodedDataset,
4643) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
4644    model.validate()?;
4645    let x_dense = build_multinomial_predict_design(model, data)?;
4646    model.predict_probabilities_with_se(x_dense.view())
4647}
4648
4649/// The same pair under an EXPLICITLY named covariance definition.
4650///
4651/// The conditional arm is reachable by name rather than only as a fallback,
4652/// which is what lets the two modes be audited against each other: the
4653/// difference between the two bands on one fit IS the smoothing-parameter
4654/// uncertainty, and a gate that can only see the default cannot tell a
4655/// correction that is present from one that is zero.
4656pub fn predict_multinomial_formula_with_se_in_mode(
4657    model: &MultinomialSavedModel,
4658    data: &EncodedDataset,
4659    mode: InferenceCovarianceMode,
4660) -> Result<(Array2<f64>, Array2<f64>), EstimationError> {
4661    model.validate()?;
4662    let x_dense = build_multinomial_predict_design(model, data)?;
4663    model.predict_probabilities_with_se_in_mode(x_dense.view(), mode)
4664}
4665
4666#[derive(Debug, Clone)]
4667pub struct MultinomialPredictionIntervals {
4668    pub mean: Array2<f64>,
4669    pub standard_error: Array2<f64>,
4670    pub mean_lower: Array2<f64>,
4671    pub mean_upper: Array2<f64>,
4672    pub level: f64,
4673    /// Exactly which posterior covariance definition the spread — and therefore
4674    /// the band — was built from. The multinomial's counterpart of
4675    /// `PredictUncertaintyResult::covariance_source`; see
4676    /// [`MultinomialSavedModel::predict_probabilities_with_se_in_mode`].
4677    pub covariance_source: InferenceCovarianceMode,
4678}
4679
4680/// Build a central posterior interval around the integrated logistic-normal
4681/// posterior mean class probability. Both centre and spread come from the same
4682/// deterministic posterior integral; no plug-in/delta quantity enters the
4683/// centre.
4684///
4685/// # The band is built on the log-odds scale, not on the probability scale
4686///
4687/// The endpoints are `expit(logit(m) ± z·sd/(m(1−m)))`, which is the
4688/// `gam_predict::MeanIntervalMethod::TransformEta` construction this library
4689/// already prefers for every nonlinear link: build the symmetric interval where
4690/// the posterior is closest to Gaussian, then carry it through a MONOTONE map.
4691///
4692/// A symmetric `m ± z·sd` band on the probability scale, clamped into `[0, 1]`,
4693/// is wrong in two ways that both bite exactly where a class probability lives.
4694/// It is symmetric about `m` when the posterior of a bounded quantity is not —
4695/// the skew grows without bound as `m` approaches an endpoint — and the clamp
4696/// silently DELETES the part of the interval that fell outside, so a nominally
4697/// 95% band can carry materially less than 95% of the posterior while still
4698/// reporting `level = 0.95`. Transforming a log-odds interval has neither
4699/// property: `expit` is a bijection onto `(0, 1)`, so no mass is ever clipped
4700/// and the asymmetry is produced by the map rather than approximated away.
4701///
4702/// At `m = ½` the two constructions agree to first order (`d logit/dp = 4` and
4703/// the transform is locally affine), so this is not a re-scaling of well-posed
4704/// bands — it is a repair of the ones near the boundary.
4705pub fn predict_multinomial_formula_with_intervals(
4706    model: &MultinomialSavedModel,
4707    data: &EncodedDataset,
4708    level: f64,
4709) -> Result<MultinomialPredictionIntervals, EstimationError> {
4710    let source = if model.smoothing_correction_flat.is_some() {
4711        InferenceCovarianceMode::SmoothingCorrected
4712    } else {
4713        InferenceCovarianceMode::Conditional
4714    };
4715    predict_multinomial_formula_with_intervals_in_mode(model, data, level, source)
4716}
4717
4718/// The same band under an EXPLICITLY named covariance definition; see
4719/// [`predict_multinomial_formula_with_se_in_mode`].
4720pub fn predict_multinomial_formula_with_intervals_in_mode(
4721    model: &MultinomialSavedModel,
4722    data: &EncodedDataset,
4723    level: f64,
4724    covariance_source: InferenceCovarianceMode,
4725) -> Result<MultinomialPredictionIntervals, EstimationError> {
4726    if !(level.is_finite() && level > 0.0 && level < 1.0) {
4727        crate::bail_invalid_estim!(
4728            "multinomial prediction interval level must be finite and in (0, 1), got {level}"
4729        );
4730    }
4731    model.validate()?;
4732    let x_dense = build_multinomial_predict_design(model, data)?;
4733    let (mean, standard_error) =
4734        model.predict_probabilities_with_se_in_mode(x_dense.view(), covariance_source)?;
4735    let z = gam_math::probability::standard_normal_quantile(0.5 + 0.5 * level)
4736        .map_err(EstimationError::InvalidInput)?;
4737    let mut mean_lower = mean.clone();
4738    let mut mean_upper = mean.clone();
4739    for ((row, class), &se) in standard_error.indexed_iter() {
4740        let (lower, upper) = log_odds_interval(mean[[row, class]], se, z);
4741        mean_lower[[row, class]] = lower;
4742        mean_upper[[row, class]] = upper;
4743    }
4744    Ok(MultinomialPredictionIntervals {
4745        mean,
4746        standard_error,
4747        mean_lower,
4748        mean_upper,
4749        level,
4750        covariance_source,
4751    })
4752}
4753
4754/// `expit(logit(m) ± z·sd/(m(1−m)))`, the monotone-transform central interval
4755/// for a probability with posterior mean `m` and posterior standard deviation
4756/// `sd`.
4757///
4758/// The delta-method log-odds spread is `sd / (m(1−m))`; `m(1−m)` is the
4759/// Jacobian `dp/dlogit p` at `m`. The degenerate ends are handled where they
4760/// arise rather than by a clamp afterwards: at `m ∈ {0, 1}` the log-odds scale
4761/// does not exist, and a mean that has reached a vertex of the simplex with
4762/// spread `sd` has no interval this transform can express, so the symmetric
4763/// probability-scale band is the honest answer THERE and only there. `sd = 0`
4764/// is a point interval on either scale.
4765fn log_odds_interval(mean: f64, standard_error: f64, z: f64) -> (f64, f64) {
4766    if !(mean.is_finite() && standard_error.is_finite()) || standard_error <= 0.0 {
4767        return (mean, mean);
4768    }
4769    let jacobian = mean * (1.0 - mean);
4770    if !(mean > 0.0 && mean < 1.0) || jacobian <= f64::MIN_POSITIVE {
4771        return (
4772            (mean - z * standard_error).clamp(0.0, 1.0),
4773            (mean + z * standard_error).clamp(0.0, 1.0),
4774        );
4775    }
4776    let center = (mean / (1.0 - mean)).ln();
4777    let half_width = z * standard_error / jacobian;
4778    let expit = |value: f64| -> f64 {
4779        if value >= 0.0 {
4780            1.0 / (1.0 + (-value).exp())
4781        } else {
4782            let e = value.exp();
4783            e / (1.0 + e)
4784        }
4785    };
4786    (expit(center - half_width), expit(center + half_width))
4787}
4788
4789#[cfg(test)]
4790mod fisher_override_tests {
4791    use super::*;
4792
4793    use ndarray::Array3;
4794
4795    fn toy() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
4796        let n = 15;
4797        let p = 2;
4798        let k = 3;
4799        let design =
4800            Array2::<f64>::from_shape_fn(
4801                (n, p),
4802                |(i, j)| {
4803                    if j == 0 { 1.0 } else { ((i + 2) as f64).cos() }
4804                },
4805            );
4806        let mut y = Array2::<f64>::zeros((n, k));
4807        for i in 0..n {
4808            y[[i, i % k]] = 1.0;
4809        }
4810        let penalty = Array2::<f64>::eye(p);
4811        // #2344: K per-class lambdas (reference class included).
4812        let lambdas = Array1::<f64>::from_elem(k, 0.5);
4813        (design, y, penalty, lambdas)
4814    }
4815
4816    #[test]
4817    fn fisher_override_none_reproduces_analytic() {
4818        // Issue #349: None override is exactly the analytic fit.
4819        let (design, y, penalty, lambdas) = toy();
4820        let mk = |over: Option<ndarray::ArrayView3<'_, f64>>| {
4821            fit_penalized_multinomial(MultinomialFitInputs {
4822                design: design.view(),
4823                y_one_hot: y.view(),
4824                penalty: penalty.view(),
4825                lambdas: lambdas.view(),
4826                row_weights: None,
4827                fisher_w_override: over,
4828                max_iter: 50,
4829                tol: 1.0e-9,
4830                resume_from: None,
4831            })
4832            .expect("fit must succeed")
4833        };
4834        let a = mk(None);
4835        let b = mk(None);
4836        for (x, z) in a
4837            .coefficients_active
4838            .iter()
4839            .zip(b.coefficients_active.iter())
4840        {
4841            assert_eq!(x, z);
4842        }
4843    }
4844
4845    #[test]
4846    fn exhausted_fixed_lambda_budget_is_typed_error_not_fit() {
4847        let (design, y, penalty, lambdas) = toy();
4848        let error = fit_penalized_multinomial(MultinomialFitInputs {
4849            design: design.view(),
4850            y_one_hot: y.view(),
4851            penalty: penalty.view(),
4852            lambdas: lambdas.view(),
4853            row_weights: None,
4854            fisher_w_override: None,
4855            max_iter: 0,
4856            tol: 1.0e-9,
4857            resume_from: None,
4858        })
4859        .expect_err("a zero-budget Newton solve must not mint a multinomial fit");
4860        assert!(matches!(
4861            error,
4862            EstimationError::FixedLambdaNewtonDidNotConverge {
4863                objective_value,
4864                checkpoint,
4865                ..
4866            } if objective_value.is_finite()
4867                && checkpoint.stage() == FixedLambdaSolverStage::MultinomialNewton
4868                && checkpoint.completed_iterations() == 0
4869        ));
4870    }
4871
4872    #[test]
4873    fn fixed_lambda_checkpoint_resume_matches_uninterrupted_solve() {
4874        let (design, y, penalty, lambdas) = toy();
4875        let interrupted = fit_penalized_multinomial(MultinomialFitInputs {
4876            design: design.view(),
4877            y_one_hot: y.view(),
4878            penalty: penalty.view(),
4879            lambdas: lambdas.view(),
4880            row_weights: None,
4881            fisher_w_override: None,
4882            max_iter: 1,
4883            tol: 1.0e-9,
4884            resume_from: None,
4885        })
4886        .expect_err("one Newton step must leave this coupled fit uncertified");
4887        let checkpoint = match interrupted {
4888            EstimationError::FixedLambdaNewtonDidNotConverge { checkpoint, .. } => checkpoint,
4889            other => panic!("unexpected interruption error: {other}"),
4890        };
4891        assert_eq!(
4892            checkpoint.stage(),
4893            FixedLambdaSolverStage::MultinomialNewton
4894        );
4895        assert_eq!(checkpoint.completed_iterations(), 1);
4896
4897        let resumed = fit_penalized_multinomial(MultinomialFitInputs {
4898            design: design.view(),
4899            y_one_hot: y.view(),
4900            penalty: penalty.view(),
4901            lambdas: lambdas.view(),
4902            row_weights: None,
4903            fisher_w_override: None,
4904            max_iter: 49,
4905            tol: 1.0e-9,
4906            resume_from: Some(&checkpoint),
4907        })
4908        .expect("resumed multinomial solve must converge");
4909        let uninterrupted = fit_penalized_multinomial(MultinomialFitInputs {
4910            design: design.view(),
4911            y_one_hot: y.view(),
4912            penalty: penalty.view(),
4913            lambdas: lambdas.view(),
4914            row_weights: None,
4915            fisher_w_override: None,
4916            max_iter: 50,
4917            tol: 1.0e-9,
4918            resume_from: None,
4919        })
4920        .expect("uninterrupted multinomial solve must converge");
4921
4922        assert_eq!(resumed.iterations, uninterrupted.iterations);
4923        assert_eq!(
4924            resumed.coefficients_active,
4925            uninterrupted.coefficients_active
4926        );
4927        assert_eq!(
4928            resumed.penalized_neg_log_likelihood,
4929            uninterrupted.penalized_neg_log_likelihood,
4930        );
4931        assert_eq!(
4932            resumed.coefficient_covariance,
4933            uninterrupted.coefficient_covariance,
4934        );
4935    }
4936
4937    #[test]
4938    fn fisher_override_wrong_shape_is_rejected() {
4939        let (design, y, penalty, lambdas) = toy();
4940        let n = design.nrows();
4941        let m = y.ncols(); // K, not K-1 — deliberately wrong
4942        let bad = Array3::<f64>::zeros((n, m, m));
4943        let err = fit_penalized_multinomial(MultinomialFitInputs {
4944            design: design.view(),
4945            y_one_hot: y.view(),
4946            penalty: penalty.view(),
4947            lambdas: lambdas.view(),
4948            row_weights: None,
4949            fisher_w_override: Some(bad.view()),
4950            max_iter: 50,
4951            tol: 1.0e-9,
4952            resume_from: None,
4953        })
4954        .expect_err("wrong active-block shape must error");
4955        assert!(format!("{err}").contains("fisher_w_override shape"));
4956    }
4957
4958    /// #1101 regression: the fixed-λ inner solve now surfaces the joint Laplace
4959    /// coefficient covariance `H⁻¹`, and the multinomial predictor derives
4960    /// finite delta-method per-class probability standard errors from it. Before
4961    /// this change `MultinomialFitOutputs` carried NO covariance at all, so the
4962    /// covariance-dimension / predictor assertions below could not even compile
4963    /// (fail-before). Asserts, with un-weakened bounds:
4964    ///   1. covariance is `(P·(K−1))²`, all-finite, symmetric, and PSD (every
4965    ///      diagonal ≥ 0 and `vᵀΣv ≥ 0` on probe vectors);
4966    ///   2. the delta-method per-class probability SEs are finite and within
4967    ///      `[0, 1]` (a probability SE can never exceed the unit interval);
4968    ///   3. predicted probabilities are finite, in `[0, 1]`, and each row sums
4969    ///      to 1 (simplex).
4970    #[test]
4971    fn covariance_and_delta_method_se_are_finite_and_wellformed_1101() {
4972        let (design, y, penalty, lambdas) = toy();
4973        let p = design.ncols();
4974        let k = y.ncols();
4975        let m = k - 1;
4976        let d = p * m;
4977
4978        let fit = fit_penalized_multinomial(MultinomialFitInputs {
4979            design: design.view(),
4980            y_one_hot: y.view(),
4981            penalty: penalty.view(),
4982            lambdas: lambdas.view(),
4983            row_weights: None,
4984            fisher_w_override: None,
4985            max_iter: 50,
4986            tol: 1.0e-9,
4987            resume_from: None,
4988        })
4989        .expect("fit must succeed");
4990        // (1) Covariance shape, finiteness, symmetry.
4991        let cov = &fit.coefficient_covariance;
4992        assert_eq!(
4993            cov.dim(),
4994            (d, d),
4995            "covariance must be (P·(K−1))² = ({d},{d})"
4996        );
4997        for &v in cov.iter() {
4998            assert!(v.is_finite(), "covariance entry must be finite (got {v})");
4999        }
5000        for i in 0..d {
5001            for j in 0..d {
5002                let asym = (cov[[i, j]] - cov[[j, i]]).abs();
5003                assert!(
5004                    asym <= 1e-9 * (1.0 + cov[[i, j]].abs()),
5005                    "covariance must be symmetric at ({i},{j}): |Σ_ij − Σ_ji| = {asym:.3e}"
5006                );
5007            }
5008        }
5009        // PSD: diagonal ≥ 0 and quadratic forms on deterministic probe vectors
5010        // (unit axes and the all-ones vector) are non-negative. `H = XᵀWX + λS`
5011        // with W PSD (softmax Fisher) and S PSD (identity here) is positive
5012        // definite, so its inverse is PD; these probes must all be positive.
5013        for i in 0..d {
5014            assert!(
5015                cov[[i, i]] >= 0.0,
5016                "covariance diagonal[{i}] must be ≥ 0 (got {})",
5017                cov[[i, i]]
5018            );
5019        }
5020        let mut probes: Vec<Vec<f64>> = Vec::new();
5021        for i in 0..d {
5022            let mut e = vec![0.0_f64; d];
5023            e[i] = 1.0;
5024            probes.push(e);
5025        }
5026        probes.push(vec![1.0_f64; d]);
5027        for v in &probes {
5028            let mut q = 0.0_f64;
5029            for i in 0..d {
5030                for j in 0..d {
5031                    q += v[i] * cov[[i, j]] * v[j];
5032                }
5033            }
5034            assert!(q >= -1e-9, "covariance must be PSD: vᵀΣv = {q:.3e} < 0");
5035        }
5036
5037        // (2) & (3) Delta-method SEs and simplex probabilities on the training
5038        // design (any P-column matrix in the fitted basis works).
5039        let (probs, prob_se) = fit
5040            .logistic_normal_softmax_moments(design.view())
5041            .expect("logistic-normal softmax moments must succeed");
5042        let n = design.nrows();
5043        assert_eq!(probs.dim(), (n, k));
5044        assert_eq!(prob_se.dim(), (n, k));
5045        for row in 0..n {
5046            let mut rowsum = 0.0_f64;
5047            for c in 0..k {
5048                let pc = probs[[row, c]];
5049                assert!(
5050                    pc.is_finite() && (0.0..=1.0).contains(&pc),
5051                    "prob[{row},{c}]={pc}"
5052                );
5053                rowsum += pc;
5054                let se = prob_se[[row, c]];
5055                assert!(
5056                    se.is_finite(),
5057                    "prob_se[{row},{c}] must be finite (got {se})"
5058                );
5059                assert!(
5060                    (0.0..=1.0).contains(&se),
5061                    "prob_se[{row},{c}] must be in [0,1] (got {se})"
5062                );
5063            }
5064            assert!(
5065                (rowsum - 1.0).abs() < 1e-9,
5066                "row {row} probabilities must sum to 1 (got {rowsum})"
5067            );
5068        }
5069    }
5070
5071    #[test]
5072    fn formula_outer_route_uses_exact_curvature_for_medium_d() {
5073        // The 2-smooth reference formula fit (K = 3, double-penalty terms)
5074        // carries 2 terms x 2 penalties = 4 components, and the equivariant
5075        // carrier gives each component one coordinate PER CLASS: D = 4 x 3 = 12.
5076        // It needs exact curvature to avoid over-smoothed lambda caps
5077        // (#715 arm (a)).
5078        assert!(
5079            multinomial_formula_use_outer_hessian(8),
5080            "D=8 loaded multinomial fits need exact curvature to avoid over-smoothed lambda caps"
5081        );
5082        assert!(
5083            multinomial_formula_use_outer_hessian(12),
5084            "D=12 (2 double-penalty smooth terms, K=3) stays on exact curvature"
5085        );
5086    }
5087
5088    #[test]
5089    fn formula_outer_route_uses_exact_curvature_for_the_penguin_fixture() {
5090        // Four k=10 penguin smooths (K = 3) are 8 double-penalty components and
5091        // therefore D = 8 x 3 = 24 outer coordinates. They must reach the exact
5092        // ARC route so the #1082 cost-stall halt is available on the
5093        // near-separable lambda-to-zero ridge.
5094        assert!(
5095            multinomial_formula_use_outer_hessian(24),
5096            "the four-smooth penguin fixture needs exact ARC curvature for the #1082 stall halt"
5097        );
5098    }
5099
5100    #[test]
5101    fn formula_min_lambda_floor_is_continuous_and_information_scaled() {
5102        // Build a one-hot label matrix whose smallest class carries `count` rows.
5103        fn floor_for_min_count(count: usize) -> f64 {
5104            // Two classes: a large one (1000 rows) and a minority one (`count`).
5105            let n = 1000 + count;
5106            let mut y = Array2::<f64>::zeros((n, 2));
5107            for r in 0..1000 {
5108                y[[r, 0]] = 1.0;
5109            }
5110            for r in 1000..n {
5111                y[[r, 1]] = 1.0;
5112            }
5113            multinomial_formula_min_lambda(y.view())
5114        }
5115
5116        // The floor's endpoints are now DERIVED from a target prior strength in
5117        // pseudo-observations against the maximal per-observation softmax Fisher
5118        // information I₁ = ¼ (base = τ·I₁, sparse = τ_max·I₁). Pin them to the
5119        // previously fixture-calibrated values so the near-separable quality arms
5120        // (penguins, vgam softmax) — whose smallest class has n_c ≥ 50 — are
5121        // byte-for-byte unaffected: the derivation REDUCES TO the old constants
5122        // at the calibration point.
5123        let base = MULTINOMIAL_FORMULA_PRIOR_PSEUDO_OBS * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
5124        let sparse = MULTINOMIAL_FORMULA_SPARSE_PRIOR_PSEUDO_OBS_MAX
5125            * MULTINOMIAL_FORMULA_FISHER_INFO_PER_OBS;
5126        assert!(
5127            (base - 2.0e-4).abs() < 1e-18,
5128            "derived base floor must equal the calibrated 2e-4"
5129        );
5130        assert!(
5131            (sparse - 1.0e-3).abs() < 1e-18,
5132            "derived sparse floor must equal the calibrated 1e-3"
5133        );
5134
5135        // Well-supported (n_c >= n_ref=50) sits exactly at the base floor.
5136        assert!((floor_for_min_count(50) - base).abs() < 1e-18);
5137        assert!((floor_for_min_count(200) - base).abs() < 1e-18);
5138        // Very sparse (n_c <= n_ref·base/sparse = 10) clamps to the strong floor.
5139        assert!((floor_for_min_count(10) - sparse).abs() < 1e-18);
5140        assert!((floor_for_min_count(5) - sparse).abs() < 1e-18);
5141        // No cliff at the old hard threshold: 49 vs 50 differ by < 5% (the old
5142        // step jumped 5x). Floor is monotone non-increasing in support.
5143        let f49 = floor_for_min_count(49);
5144        let f50 = floor_for_min_count(50);
5145        assert!(
5146            f49 >= f50 && f49 <= f50 * 1.05,
5147            "floor must be continuous across c0, got {f49} vs {f50}"
5148        );
5149        let f25 = floor_for_min_count(25);
5150        assert!(
5151            f25 > f50 && f25 < floor_for_min_count(10),
5152            "mid-support floor must interpolate strictly between the two endpoints"
5153        );
5154
5155        // FIRST-PRINCIPLES SCALING: in the interpolating regime the floor equals
5156        // exactly τ·I₁·(n_ref/n_c) — the effective-pseudo-observation prior held
5157        // to a fixed fraction of the per-class data information n_c·I₁. Halving
5158        // the effective sample size doubles the floor (until the cap), and the
5159        // absolute value matches the closed-form n_c-scaled prior.
5160        for &n_c in &[12usize, 16, 20, 30, 40] {
5161            let expected = base * (MULTINOMIAL_FORMULA_SPARSE_REFERENCE_SUPPORT / n_c as f64);
5162            assert!(
5163                (floor_for_min_count(n_c) - expected).abs() < 1e-15,
5164                "floor at n_c={n_c} must be τ·I₁·n_ref/n_c = {expected}, got {}",
5165                floor_for_min_count(n_c)
5166            );
5167        }
5168        // Inverse scaling with effective sample size: n_c -> n_c/2 doubles the
5169        // floor inside the unclamped band (20 and 40 are both interior; 40 < 50
5170        // so it is scaled, 20 > 10 so it is not capped).
5171        assert!(
5172            (floor_for_min_count(20) - 2.0 * floor_for_min_count(40)).abs() < 1e-15,
5173            "floor must scale like 1/n_c (effective Fisher information) in the interior band"
5174        );
5175    }
5176
5177    #[test]
5178    fn formula_penalty_scale_tracks_softmax_fisher_curvature() {
5179        assert!(
5180            (multinomial_formula_penalty_scale(2) - 0.5).abs() < 1.0e-12,
5181            "binary-logit neutral-simplex curvature scale should remain at 1/2"
5182        );
5183        assert!(
5184            (multinomial_formula_penalty_scale(3) - 4.0 / 9.0).abs() < 1.0e-12,
5185            "three-class softmax penalties should be calibrated to 2*(K-1)/K^2"
5186        );
5187        assert!(
5188            multinomial_formula_penalty_scale(5) < multinomial_formula_penalty_scale(3),
5189            "active-class Fisher curvature decreases as the simplex gains classes"
5190        );
5191    }
5192
5193    #[test]
5194    fn fixed_lambda_multinomial_firth_keeps_complete_separation_finite() {
5195        // #1854: complete softmax separation used to be a HARD diagnostic
5196        // (`MultinomialSeparationDetected`). It now automatically engages the
5197        // Firth/Jeffreys proper prior (`½ log|I(β)|`, magic-by-default) so the fit
5198        // stays finite instead of running away — the same guarantee the formula
5199        // REML path already provided. The class regions are cleanly separated by
5200        // `x`, so the unbiased MLE is at infinity; the Firth-penalized fit must
5201        // still converge to a finite mode and recover the region structure.
5202        let n = 90;
5203        let design = Array2::<f64>::from_shape_fn((n, 2), |(row, col)| match col {
5204            0 => 1.0,
5205            _ => -3.0 + 6.0 * (row as f64) / ((n - 1) as f64),
5206        });
5207        let mut y = Array2::<f64>::zeros((n, 3));
5208        for row in 0..n {
5209            let x = design[[row, 1]];
5210            let class = if x < -1.0 {
5211                0
5212            } else if x > 1.0 {
5213                1
5214            } else {
5215                2
5216            };
5217            y[[row, class]] = 1.0;
5218        }
5219        let penalty = Array2::<f64>::zeros((2, 2));
5220        // #2344: K per-class lambdas (reference class included); K = 3 here.
5221        let lambdas = Array1::<f64>::zeros(3);
5222        let out = fit_penalized_multinomial(MultinomialFitInputs {
5223            design: design.view(),
5224            y_one_hot: y.view(),
5225            penalty: penalty.view(),
5226            lambdas: lambdas.view(),
5227            row_weights: None,
5228            fisher_w_override: None,
5229            max_iter: 80,
5230            tol: 1.0e-12,
5231            resume_from: None,
5232        })
5233        .expect("Firth/Jeffreys prior keeps the separated multinomial fit finite (#1854)");
5234        // Every coefficient is finite — the whole point of the Firth prior on the
5235        // separated (unpenalized) logit directions.
5236        for &b in out.coefficients_active.iter() {
5237            assert!(
5238                b.is_finite(),
5239                "Firth-penalized coefficients must be finite, got {b}"
5240            );
5241        }
5242        // Fitted probabilities remain a valid simplex per row.
5243        for row in 0..n {
5244            let mut mass = 0.0_f64;
5245            for c in 0..3 {
5246                let p = out.fitted_probabilities[[row, c]];
5247                assert!(
5248                    p.is_finite() && (0.0..=1.0 + 1e-9).contains(&p),
5249                    "row {row} class {c} probability {p} out of [0,1]"
5250                );
5251                mass += p;
5252            }
5253            assert!(
5254                (mass - 1.0).abs() < 1e-6,
5255                "row {row} probabilities must sum to 1, got {mass}"
5256            );
5257        }
5258        // The finite fit still recovers the separated structure: on a clearly
5259        // interior representative of each region the predicted class is correct.
5260        let predict = |x: f64| -> usize {
5261            let mut eta = [0.0_f64; 3];
5262            for a in 0..2 {
5263                eta[a] = out.coefficients_active[[0, a]] + out.coefficients_active[[1, a]] * x;
5264            }
5265            let mut best = 0usize;
5266            for c in 1..3 {
5267                if eta[c] > eta[best] {
5268                    best = c;
5269                }
5270            }
5271            best
5272        };
5273        assert_eq!(predict(-2.5), 0, "deep-left region should predict class 0");
5274        assert_eq!(predict(2.5), 1, "deep-right region should predict class 1");
5275        assert_eq!(predict(0.0), 2, "central region should predict class 2");
5276    }
5277
5278    #[test]
5279    fn formula_multinomial_accepts_finite_saturated_logits() {
5280        // A saturated-but-FINITE logit surface can be a valid formula REML mode
5281        // (the #715 penguins regime: bill/flipper cleanly separate the species,
5282        // so fitted logits can legitimately exceed ±25). `outer_converged ==
5283        // false` then signals only that the driver auto-escalated to never-fail
5284        // posterior sampling about that finite mode (gam#860), NOT a separation
5285        // artifact — the adapter must accept it, never raise
5286        // `MultinomialSeparationDetected`.
5287        let saturated_states = vec![
5288            ParameterBlockState {
5289                beta: Array1::from_vec(vec![1.0, 2.0]),
5290                eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
5291            },
5292            ParameterBlockState {
5293                beta: Array1::from_vec(vec![-1.0, 3.0]),
5294                eta: Array1::from_vec(vec![1.0, 25.5, -0.1]),
5295            },
5296        ];
5297        assert!(
5298            multinomial_formula_separation_diagnostic(17, 9, &saturated_states).is_none(),
5299            "a finite (even saturated, |eta|>25) formula optimum is a valid fit, \
5300             not a separation diagnostic"
5301        );
5302
5303        // Only a genuinely NON-FINITE logit — a NaN/Inf blow-up in the inner
5304        // linear algebra with no finite mode to sample about — is a real
5305        // formula-path failure.
5306        let blown_up = vec![
5307            ParameterBlockState {
5308                beta: Array1::from_vec(vec![1.0, 2.0]),
5309                eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
5310            },
5311            ParameterBlockState {
5312                beta: Array1::from_vec(vec![-1.0, 3.0]),
5313                eta: Array1::from_vec(vec![1.0, f64::INFINITY, -0.1]),
5314            },
5315        ];
5316        let err = multinomial_formula_separation_diagnostic(17, 9, &blown_up)
5317            .expect("a non-finite formula logit must raise the separation diagnostic");
5318        assert!(
5319            matches!(
5320                err,
5321                EstimationError::MultinomialSeparationDetected {
5322                    iteration: 17,
5323                    max_abs_eta,
5324                    active_class_index: 1,
5325                    row_index: 1,
5326                } if !max_abs_eta.is_finite()
5327            ),
5328            "expected typed multinomial separation diagnostic at the non-finite channel, got {err:?}"
5329        );
5330    }
5331
5332    #[test]
5333    fn separation_evidence_gate_arms_firth_only_on_blowup() {
5334        // Interior fit: finite logits well inside the saturation threshold ⇒ NO
5335        // separation evidence ⇒ the unbiased criterion's mode is accepted as-is
5336        // and the Firth/Jeffreys prior stays disarmed (#715 arm (a): no 1/K
5337        // shrinkage on well-identified data).
5338        let interior = vec![
5339            ParameterBlockState {
5340                beta: Array1::from_vec(vec![1.0, 2.0]),
5341                eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
5342            },
5343            ParameterBlockState {
5344                beta: Array1::from_vec(vec![-1.0, 3.0]),
5345                eta: Array1::from_vec(vec![1.0, -3.5, -0.1]),
5346            },
5347        ];
5348        assert!(
5349            multinomial_formula_separation_evidence(&interior).is_none(),
5350            "an interior finite mode must not arm the Firth refit"
5351        );
5352
5353        // Saturated but finite logits are valid formula-path modes on
5354        // near-separated real data. They must not arm the Firth refit because
5355        // the Jeffreys pull can over-regularize the held-out probabilities.
5356        let saturated = vec![
5357            ParameterBlockState {
5358                beta: Array1::from_vec(vec![1.0, 2.0]),
5359                eta: Array1::from_vec(vec![0.2, 4.0, -7.0]),
5360            },
5361            ParameterBlockState {
5362                beta: Array1::from_vec(vec![-1.0, 3.0]),
5363                eta: Array1::from_vec(vec![1.0, 25.5, -0.1]),
5364            },
5365        ];
5366        assert!(
5367            multinomial_formula_separation_evidence(&saturated).is_none(),
5368            "a finite saturated formula-mode logit must not arm the Firth refit"
5369        );
5370
5371        // Non-finite logit ⇒ inner blow-up along an unbounded direction ⇒
5372        // separation evidence.
5373        let blown_up = vec![ParameterBlockState {
5374            beta: Array1::from_vec(vec![1.0, 2.0]),
5375            eta: Array1::from_vec(vec![0.2, f64::NAN, -7.0]),
5376        }];
5377        let evidence = multinomial_formula_separation_evidence(&blown_up)
5378            .expect("a non-finite logit is separation evidence");
5379        assert!(
5380            evidence.contains("non-finite logit") && evidence.contains("row 1"),
5381            "evidence must name the non-finite logit, got {evidence}"
5382        );
5383
5384        // Large finite logits below the fixed-lambda diagnostic threshold are
5385        // likewise accepted on the formula path.
5386        let near = vec![ParameterBlockState {
5387            beta: Array1::from_vec(vec![1.0, 2.0]),
5388            eta: Array1::from_vec(vec![0.2, 24.9, -24.9]),
5389        }];
5390        assert!(
5391            multinomial_formula_separation_evidence(&near).is_none(),
5392            "logits below the saturation threshold must not arm the Firth refit"
5393        );
5394    }
5395
5396    #[test]
5397    fn fisher_certificate_distinguishes_finite_quasi_separation_2612() {
5398        fn fixture(rows: usize, separated: bool) -> (MultinomialFamily, Vec<ParameterBlockState>) {
5399            let design = Arc::new(Array2::<f64>::from_shape_fn((rows, 2), |(row, column)| {
5400                if column == 0 {
5401                    1.0
5402                } else {
5403                    let side = if row % 2 == 0 { -1.0 } else { 1.0 };
5404                    side * (1.0 + row as f64 / rows as f64)
5405                }
5406            }));
5407            let mut response = Array2::<f64>::zeros((rows, 3));
5408            for row in 0..rows {
5409                response[[row, row % 3]] = 1.0;
5410            }
5411            let family = MultinomialFamily::new(
5412                response,
5413                Array1::ones(rows),
5414                3,
5415                Arc::clone(&design),
5416                Arc::new(vec![PenaltyMatrix::Dense(Array2::eye(2))]),
5417            )
5418            .expect("finite three-class fixture");
5419            let slopes = if separated { [30.0, -30.0] } else { [0.0, 0.0] };
5420            let states = slopes
5421                .into_iter()
5422                .map(|slope| {
5423                    let beta = Array1::from_vec(vec![0.0, slope]);
5424                    let eta = design.dot(&beta);
5425                    ParameterBlockState { beta, eta }
5426                })
5427                .collect();
5428            (family, states)
5429        }
5430
5431        let (separated_family, separated_states) = fixture(60, true);
5432        assert!(
5433            multinomial_formula_separation_evidence(&separated_states).is_none(),
5434            "finite logits alone deliberately carry no separation evidence"
5435        );
5436        let separated_specs = separated_family.build_block_specs();
5437        let separated_span = Array2::<f64>::eye(4);
5438        let evidence = multinomial_formula_penalized_separation_evidence(
5439            &separated_family,
5440            &separated_specs,
5441            &separated_states,
5442            separated_span.view(),
5443            &[],
5444            0,
5445            None,
5446        )
5447        .expect("exact Fisher certification")
5448        .expect("finite quasi-separation must arm the Firth refit");
5449        let evidence = evidence.evidence;
5450        assert!(
5451            evidence.contains("under-identified")
5452                && evidence.contains("lambda_min")
5453                && evidence.contains("Jeffreys gate weight"),
5454            "the certificate must report its authoritative spectrum, got {evidence}"
5455        );
5456
5457        let (identified_family, identified_states) = fixture(600, false);
5458        let identified_specs = identified_family.build_block_specs();
5459        let identified_span = Array2::<f64>::eye(4);
5460        assert!(
5461            multinomial_formula_penalized_separation_evidence(
5462                &identified_family,
5463                &identified_specs,
5464                &identified_states,
5465                identified_span.view(),
5466                &[],
5467                0,
5468                None,
5469            )
5470            .expect("exact Fisher certification")
5471            .is_none(),
5472            "a finite, well-identified Fisher geometry must keep Firth disarmed"
5473        );
5474    }
5475
5476    /// #2612: the certificate must judge the curvature the FIT HAS, not the
5477    /// curvature the likelihood alone supplies.
5478    ///
5479    /// The absolute conditioning gate fires when the worst-determined direction
5480    /// holds less than one observation-equivalent (`λ_min < 1`). A penalized
5481    /// direction routinely sits there — that is *why* it is penalized — so a
5482    /// certificate reading the bare Fisher information `H` calls every penalized
5483    /// smooth "separated". The discriminator: hold the data, the mode and the
5484    /// span fixed and supply ONLY the penalty the fit actually selected. If the
5485    /// verdict is unchanged the certificate is ignoring `S_λ`; if it flips to
5486    /// disarmed, `λ` was reaching that direction all along.
5487    ///
5488    /// This is a unit statement about the certificate, independent of any fit or
5489    /// optimizer, so it holds even if every end-to-end fixture changes shape.
5490    #[test]
5491    fn the_separation_certificate_counts_the_penalty_the_fit_selected_2612() {
5492        let rows = 400usize;
5493        // Column 1 is a deliberately weak direction: `s` scales its contribution
5494        // to `X'WX` by `s²`, so `λ_min(H)` lands well under one
5495        // observation-equivalent while the intercept stays strongly determined.
5496        let weak_scale = 2.0e-2_f64;
5497        let design = Arc::new(Array2::<f64>::from_shape_fn((rows, 2), |(row, column)| {
5498            if column == 0 {
5499                1.0
5500            } else {
5501                weak_scale * ((row as f64 / rows as f64) - 0.5)
5502            }
5503        }));
5504        let mut response = Array2::<f64>::zeros((rows, 3));
5505        for row in 0..rows {
5506            response[[row, row % 3]] = 1.0;
5507        }
5508        let family = MultinomialFamily::new(
5509            response,
5510            Array1::ones(rows),
5511            3,
5512            Arc::clone(&design),
5513            Arc::new(vec![PenaltyMatrix::Dense(Array2::eye(2))]),
5514        )
5515        .expect("weak-direction three-class fixture");
5516        let specs = family.build_block_specs();
5517        let states: Vec<ParameterBlockState> = (0..2)
5518            .map(|_| {
5519                let beta = Array1::from_vec(vec![0.0, 0.0]);
5520                let eta = design.dot(&beta);
5521                ParameterBlockState { beta, eta }
5522            })
5523            .collect();
5524        let span = Array2::<f64>::eye(4);
5525
5526        // Without the penalty the weak direction reads as separation.
5527        let unpenalized = multinomial_formula_penalized_separation_evidence(
5528            &family,
5529            &specs,
5530            &states,
5531            span.view(),
5532            &[],
5533            0,
5534            None,
5535        )
5536        .expect("certification")
5537        .expect(
5538            "the fixture must be one the LIKELIHOOD alone cannot identify, or this test proves \
5539             nothing",
5540        );
5541        let unpenalized = unpenalized.evidence;
5542        assert!(
5543            unpenalized.contains("lambda_min"),
5544            "the certificate must report the spectrum it decided on, got {unpenalized}"
5545        );
5546
5547        // The same mode, with a penalty on the weak direction worth many
5548        // observation-equivalents. `S_λ` is what the fit's own inner solve adds
5549        // to `H`, so the direction is determined and nothing is separated.
5550        let mut penalty = Array2::<f64>::zeros((4, 4));
5551        penalty[[1, 1]] = 1.0;
5552        penalty[[3, 3]] = 1.0;
5553        let spec = gam_problem::JointPenaltySpec {
5554            matrix: penalty,
5555            initial_log_lambda: 0.0,
5556            nullspace_dim: 2,
5557            label: None,
5558            group: None,
5559        };
5560        let selected = Array1::from_vec(vec![(1.0e3_f64).ln()]);
5561        let penalized = multinomial_formula_penalized_separation_evidence(
5562            &family,
5563            &specs,
5564            &states,
5565            span.view(),
5566            std::slice::from_ref(&spec),
5567            1,
5568            Some(&selected),
5569        )
5570        .expect("certification");
5571        assert!(
5572            penalized.is_none(),
5573            "a direction the selected smoothing parameter determines is not separation \
5574             evidence; the certificate still armed with S_lambda supplied: {penalized:?}"
5575        );
5576    }
5577
5578    #[test]
5579    fn scaled_fisher_override_changes_first_step() {
5580        // Curvature scaled by 4× shrinks the first Newton step relative to the
5581        // analytic fit, so a single-iteration fit must differ.
5582        let (design, y, penalty, lambdas) = toy();
5583        let n = design.nrows();
5584        let m = y.ncols() - 1;
5585        // #2344: toy() now carries K per-class lambdas for the multinomial
5586        // ENTRY; the direct Centered-metric ENGINE calls below read
5587        // M = lambdas.len(), so hand them the M-length shared-lambda vector.
5588        let engine_lambdas = Array1::<f64>::from_elem(m, lambdas[0]);
5589        // Analytic block at β = 0: p_a = 1/K = 1/3, so diag = p_a(1−p_a),
5590        // off-diag = −p_a p_b. Scale that exact block by 4.
5591        let pk = 1.0 / (y.ncols() as f64);
5592        let mut over = Array3::<f64>::zeros((n, m, m));
5593        for row in 0..n {
5594            for a in 0..m {
5595                for b in 0..m {
5596                    let analytic = if a == b { pk * (1.0 - pk) } else { -pk * pk };
5597                    over[[row, a, b]] = 4.0 * analytic;
5598                }
5599            }
5600        }
5601        let likelihood =
5602            MultinomialLogitLikelihood::with_classes(y.ncols()).expect("test class count is valid");
5603        let scaled = fit_penalized_vector_glm(
5604            PenalizedVectorGlmInputs {
5605                design: design.view(),
5606                y: y.view(),
5607                penalty: penalty.view(),
5608                lambdas: engine_lambdas.view(),
5609                fisher_w_override: Some(over.view()),
5610                max_iter: 1,
5611                tol: 1.0e-9,
5612                class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Centered,
5613                resume_from: None,
5614            },
5615            &likelihood,
5616            "multinomial scaled-curvature first-step test",
5617        )
5618        .expect("scaled-curvature engine step must be finite");
5619        let analytic = fit_penalized_vector_glm(
5620            PenalizedVectorGlmInputs {
5621                design: design.view(),
5622                y: y.view(),
5623                penalty: penalty.view(),
5624                lambdas: engine_lambdas.view(),
5625                fisher_w_override: None,
5626                max_iter: 1,
5627                tol: 1.0e-9,
5628                class_penalty_metric: crate::penalized_vector_glm::ClassPenaltyMetric::Centered,
5629                resume_from: None,
5630            },
5631            &likelihood,
5632            "multinomial analytic-curvature first-step test",
5633        )
5634        .expect("analytic-curvature engine step must be finite");
5635        let checkpoint_coefficients = |solve| match solve {
5636            VectorGlmSolve::Converged(fit) => fit.coefficients,
5637            VectorGlmSolve::Stalled(stall) => stall.coefficients,
5638        };
5639        let scaled = checkpoint_coefficients(scaled);
5640        let analytic = checkpoint_coefficients(analytic);
5641        let differs = scaled
5642            .iter()
5643            .zip(analytic.iter())
5644            .any(|(a, b)| (a - b).abs() > 1.0e-6);
5645        assert!(differs, "scaled curvature must change the first step");
5646    }
5647}
5648
5649#[cfg(test)]
5650mod separation_firth_tests {
5651    //! Regression for #1854: on (quasi-)perfect separation the fixed-λ direct
5652    //! multinomial solve must engage the Firth/Jeffreys penalty and return a
5653    //! finite, converged, well-behaved fit instead of hard-erroring with
5654    //! `MultinomialSeparationDetected`.
5655    use super::*;
5656
5657    /// A perfectly linearly separable 3-class problem with an UNPENALIZED design
5658    /// (`S = 0`), so no smoothing `λ` can bound the saturated logits — only the
5659    /// Firth prior `½ log det I(β)` keeps the estimate finite. The unbiased MLE
5660    /// here runs `|η| → ∞` (separation), which is exactly the #1854 trigger.
5661    fn separated_three_class() -> (Array2<f64>, Array2<f64>, Array2<f64>, Array1<f64>) {
5662        let n = 21;
5663        let p = 2; // intercept + ordering covariate x
5664        let k = 3;
5665        let mut design = Array2::<f64>::zeros((n, p));
5666        let mut y = Array2::<f64>::zeros((n, k));
5667        for i in 0..n {
5668            let x = -3.0 + 6.0 * (i as f64) / ((n - 1) as f64);
5669            design[[i, 0]] = 1.0;
5670            design[[i, 1]] = x;
5671            let cls = if x < -1.0 {
5672                0
5673            } else if x < 1.0 {
5674                1
5675            } else {
5676                2
5677            };
5678            y[[i, cls]] = 1.0;
5679        }
5680        // S = 0: no smoothing direction can bound the separated logits.
5681        let penalty = Array2::<f64>::zeros((p, p));
5682        // #2344: K per-class lambdas (reference class included).
5683        let lambdas = Array1::<f64>::from_elem(k, 1.0);
5684        (design, y, penalty, lambdas)
5685    }
5686
5687    #[test]
5688    fn separation_engages_firth_finite_converged_fit() {
5689        let (design, y, penalty, lambdas) = separated_three_class();
5690        let out = fit_penalized_multinomial(MultinomialFitInputs {
5691            design: design.view(),
5692            y_one_hot: y.view(),
5693            penalty: penalty.view(),
5694            lambdas: lambdas.view(),
5695            row_weights: None,
5696            fisher_w_override: None,
5697            max_iter: 300,
5698            tol: 1e-10,
5699            resume_from: None,
5700        })
5701        .expect("separated multinomial must engage Firth and return a fit, not error");
5702
5703        assert!(
5704            out.coefficients_active.iter().all(|v| v.is_finite()),
5705            "all coefficients must be finite under the Firth prior"
5706        );
5707        assert!(out.deviance.is_finite(), "deviance must be finite");
5708
5709        // The runaway MLE would drive fitted probabilities to the {0,1} boundary;
5710        // the Firth prior keeps them strictly interior.
5711        for v in out.fitted_probabilities.iter() {
5712            assert!(
5713                *v > 0.0 && *v < 1.0,
5714                "Firth fit must stay interior, got p={v}"
5715            );
5716        }
5717
5718        // Perfect separation ⇒ every training row classified to its true class.
5719        let n = design.nrows();
5720        let k = y.ncols();
5721        for i in 0..n {
5722            let mut best = 0usize;
5723            for c in 1..k {
5724                if out.fitted_probabilities[[i, c]] > out.fitted_probabilities[[i, best]] {
5725                    best = c;
5726                }
5727            }
5728            let truth = (0..k)
5729                .find(|&c| y[[i, c]] == 1.0)
5730                .expect("one-hot truth class");
5731            assert_eq!(best, truth, "row {i} misclassified under separation");
5732        }
5733    }
5734
5735    #[test]
5736    fn separation_firth_returns_finite_wellshaped_covariance() {
5737        // Distinct angle: the Firth separation path must also expose a finite,
5738        // correctly-shaped (P·M × P·M) Laplace coefficient covariance — the
5739        // downstream SE machinery consumes it. A runaway MLE would have a
5740        // singular (non-invertible) information here.
5741        let (design, y, penalty, lambdas) = separated_three_class();
5742        let p = design.ncols();
5743        let k = y.ncols();
5744        let m = k - 1;
5745        let out = fit_penalized_multinomial(MultinomialFitInputs {
5746            design: design.view(),
5747            y_one_hot: y.view(),
5748            penalty: penalty.view(),
5749            lambdas: lambdas.view(),
5750            row_weights: None,
5751            fisher_w_override: None,
5752            max_iter: 300,
5753            tol: 1e-10,
5754            resume_from: None,
5755        })
5756        .expect("separated multinomial must return a Firth fit");
5757
5758        assert_eq!(
5759            out.coefficient_covariance.dim(),
5760            (p * m, p * m),
5761            "covariance must be P·M square"
5762        );
5763        assert!(
5764            out.coefficient_covariance.iter().all(|v| v.is_finite()),
5765            "Firth covariance entries must be finite"
5766        );
5767        // A genuine Laplace covariance is PSD ⇒ non-negative diagonal.
5768        for i in 0..(p * m) {
5769            assert!(
5770                out.coefficient_covariance[[i, i]] >= -1e-9,
5771                "covariance diagonal must be non-negative, got {}",
5772                out.coefficient_covariance[[i, i]]
5773            );
5774        }
5775    }
5776
5777    #[test]
5778    fn firth_solver_rejects_a_truncated_iterate() {
5779        // #2066 / SPEC 20 (convergence honesty): the Firth Newton loop may only
5780        // construct a fit after certifying stationarity. Before the fix a
5781        // truncated solve returned coefficients and covariance behind a false
5782        // `converged` flag; now budget exhaustion is a typed error carrying the
5783        // iteration count and objective evidence.
5784        //
5785        // Angle: run the SAME separated problem that converges under a full
5786        // budget (`separation_engages_firth_finite_converged_fit`) but starve the
5787        // iteration budget so it provably cannot reach the interior Firth mode.
5788        // The honest outcome is a typed error, not an inspectable fit.
5789        let (design, y, penalty, lambdas) = separated_three_class();
5790
5791        let truncated = fit_penalized_multinomial_firth_fallback(
5792            design.view(),
5793            y.view(),
5794            penalty.view(),
5795            lambdas.view(),
5796            None,
5797            1, // one Newton iteration — far from the separated mode
5798            1e-12,
5799            None,
5800        )
5801        .expect_err("a one-iteration Firth solve must not mint a fit");
5802        let checkpoint = match truncated {
5803            EstimationError::FixedLambdaNewtonDidNotConverge {
5804                objective_value,
5805                stationarity,
5806                checkpoint,
5807                ..
5808            } => {
5809                assert!(objective_value.is_finite());
5810                assert_eq!(stationarity.kind, FixedLambdaResidualKind::NewtonDecrement);
5811                assert_eq!(checkpoint.stage(), FixedLambdaSolverStage::MultinomialFirth);
5812                assert_eq!(checkpoint.completed_iterations(), 1);
5813                checkpoint
5814            }
5815            other => panic!("unexpected Firth interruption error: {other}"),
5816        };
5817
5818        let resumed = fit_penalized_multinomial(MultinomialFitInputs {
5819            design: design.view(),
5820            y_one_hot: y.view(),
5821            penalty: penalty.view(),
5822            lambdas: lambdas.view(),
5823            row_weights: None,
5824            fisher_w_override: None,
5825            max_iter: 299,
5826            tol: 1e-10,
5827            resume_from: Some(&checkpoint),
5828        })
5829        .expect("Firth checkpoint must resume to the certified mode");
5830
5831        // Contrast: with a full budget the same problem does reach stationarity
5832        // and returns the convergence-only result type.
5833        let uninterrupted = fit_penalized_multinomial_firth_fallback(
5834            design.view(),
5835            y.view(),
5836            penalty.view(),
5837            lambdas.view(),
5838            None,
5839            300,
5840            1e-10,
5841            None,
5842        )
5843        .expect("Firth fallback must converge under a full budget");
5844        assert_eq!(resumed.iterations, uninterrupted.iterations);
5845        assert_eq!(
5846            resumed.coefficients_active,
5847            uninterrupted.coefficients_active
5848        );
5849        assert_eq!(
5850            resumed.penalized_neg_log_likelihood,
5851            uninterrupted.penalized_neg_log_likelihood,
5852        );
5853        assert_eq!(
5854            resumed.coefficient_covariance,
5855            uninterrupted.coefficient_covariance,
5856        );
5857    }
5858}
5859
5860#[cfg(test)]
5861mod reference_class_invariance_tests {
5862    //! Regression for #1587: a penalized multinomial-logit GAM fit must be
5863    //! invariant to which class is the (arbitrary) softmax reference/baseline.
5864    //!
5865    //! The production REML path (`fit_penalized_multinomial_formula`) reference-
5866    //! codes the `K` classes (the last sorted label is the baseline) and, with
5867    //! the legacy `Diagonal` penalty metric, penalizes only the `K−1`
5868    //! reference-anchored ALR contrasts `½ Σ_a λ_a β_aᵀ S β_a`. Relabeling the
5869    //! response so a *different* class sorts last penalizes a different frame of
5870    //! log-odds contrasts, so the predicted probabilities drift (~1e-2 absolute)
5871    //! even though they are mathematically independent of the reference choice.
5872    //!
5873    //! This test fits the SAME 3-class softmax sample under three cyclic
5874    //! relabelings — each making a different original class the baseline —
5875    //! realigns the predicted probability columns back to the original class
5876    //! identities, and asserts the cross-labeling drift is below `1e-3`
5877    //! (the defect is ~1e-2; refitting the same labeling twice agrees to
5878    //! ~1e-12). It is the Rust-level sibling of
5879    //! `tests/bug_hunt_multinomial_fit_depends_on_reference_class_test.py`.
5880
5881    use super::*;
5882    use gam_data::load_dataset_projected;
5883    use gam_linalg::faer_ndarray::FaerEigh;
5884    use std::fmt::Write as _;
5885    use std::fs;
5886    use tempfile::tempdir;
5887
5888    /// Deterministic `splitmix64` → `[0,1)` uniform stream (no external RNG dep;
5889    /// the only requirement is a well-distributed, reproducible draw).
5890    struct SplitMix64(u64);
5891    impl SplitMix64 {
5892        fn next_u64(&mut self) -> u64 {
5893            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
5894            let mut z = self.0;
5895            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
5896            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
5897            z ^ (z >> 31)
5898        }
5899        fn unit(&mut self) -> f64 {
5900            // 53-bit mantissa uniform in [0, 1).
5901            (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
5902        }
5903    }
5904
5905    /// Draw a clean 3-class softmax regression sample (the issue's generator).
5906    /// Returns `(x, class)` with integer classes `0/1/2`.
5907    fn sample_classes(seed: u64, n: usize) -> (Vec<f64>, Vec<usize>) {
5908        let mut rng = SplitMix64(seed.wrapping_add(0x1234_5678));
5909        let mut x = Vec::with_capacity(n);
5910        let mut cls = Vec::with_capacity(n);
5911        for _ in 0..n {
5912            let xi = -2.0 + 4.0 * rng.unit();
5913            let eta = [0.5 + 0.8 * xi, -0.3 - 0.5 * xi, 0.0];
5914            let mut p = [eta[0].exp(), eta[1].exp(), eta[2].exp()];
5915            let s: f64 = p.iter().sum();
5916            for v in &mut p {
5917                *v /= s;
5918            }
5919            // Inverse-CDF draw into one of the 3 classes.
5920            let u = rng.unit();
5921            let c = if u < p[0] {
5922                0
5923            } else if u < p[0] + p[1] {
5924                1
5925            } else {
5926                2
5927            };
5928            x.push(xi);
5929            cls.push(c);
5930        }
5931        (x, cls)
5932    }
5933
5934    /// Build an `EncodedDataset` with columns `x` (numeric) and `y`
5935    /// (categorical, from the given string labels) by round-tripping a CSV.
5936    fn dataset_xy(
5937        dir: &std::path::Path,
5938        tag: &str,
5939        x: &[f64],
5940        y: &[String],
5941    ) -> gam_data::EncodedDataset {
5942        let path = dir.join(format!("data_{tag}.csv"));
5943        let mut csv = String::from("x,y\n");
5944        for (xi, yi) in x.iter().zip(y.iter()) {
5945            writeln!(csv, "{xi},{yi}").unwrap();
5946        }
5947        fs::write(&path, csv).expect("write training csv");
5948        load_dataset_projected(&path, &["x".to_string(), "y".to_string()])
5949            .expect("load training dataset")
5950    }
5951
5952    /// Fit `y ~ s(x)` under the relabeling `name_map` (original class `c` gets
5953    /// label `name_map[c]`), predict on `grid`, and return the predicted
5954    /// probabilities **realigned to the original class order** 0/1/2, shape
5955    /// `(grid.len(), 3)`.
5956    fn fit_predict_aligned(
5957        dir: &std::path::Path,
5958        tag: &str,
5959        x: &[f64],
5960        cls: &[usize],
5961        name_map: [&str; 3],
5962        grid: &[f64],
5963    ) -> Array2<f64> {
5964        let labels: Vec<String> = cls.iter().map(|&c| name_map[c].to_string()).collect();
5965        let train = dataset_xy(dir, tag, x, &labels);
5966        let config = FitConfig::default();
5967        let model = fit_penalized_multinomial_formula(&MultinomialFitRequest {
5968            init_lambda: 1.0,
5969            max_iter: 60,
5970            tol: 1e-6,
5971            ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
5972        })
5973        .expect("multinomial formula fit must succeed");
5974
5975        // Predict on the grid. The categorical `y` column is not needed for
5976        // prediction, but the schema is simplest if we supply a dummy.
5977        let grid_y: Vec<String> = grid.iter().map(|_| name_map[0].to_string()).collect();
5978        let grid_ds = dataset_xy(dir, &format!("{tag}_grid"), grid, &grid_y);
5979        let probs = predict_multinomial_formula(&model, &grid_ds)
5980            .expect("multinomial predict must succeed");
5981
5982        // `model.class_levels` is the sorted label order; the column for original
5983        // class `c` is at the rank of `name_map[c]` among the sorted labels.
5984        let mut sorted: Vec<&str> = name_map.to_vec();
5985        sorted.sort_unstable();
5986        let col_of_orig: Vec<usize> = (0..3)
5987            .map(|c| sorted.iter().position(|l| *l == name_map[c]).unwrap())
5988            .collect();
5989        // Sanity: the model's class_levels must match the sorted labels.
5990        assert_eq!(
5991            model.class_levels,
5992            sorted.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
5993            "class_levels must be the sorted label order"
5994        );
5995        let n = grid.len();
5996        let mut aligned = Array2::<f64>::zeros((n, 3));
5997        for r in 0..n {
5998            for c in 0..3 {
5999                aligned[[r, c]] = probs[[r, col_of_orig[c]]];
6000            }
6001        }
6002        aligned
6003    }
6004
6005    fn max_abs_diff(a: &Array2<f64>, b: &Array2<f64>) -> f64 {
6006        a.iter()
6007            .zip(b.iter())
6008            .map(|(p, q)| (p - q).abs())
6009            .fold(0.0_f64, f64::max)
6010    }
6011
6012    /// #2579 diagnostic (zz_measure): print the λ that
6013    /// `multinomial_fit_is_invariant_to_reference_class_1587` actually selects
6014    /// in each of its three labelings.
6015    ///
6016    /// The `#1587` gate asserts that predicted probabilities are invariant to
6017    /// the reference class. On penguins every one of the sixteen selected λ is
6018    /// railed at `exp(EFFECTIVE_DF_CEILING) = 162754.79141900392` to the last
6019    /// bit, and all-λ-equal is invariant TRIVIALLY. If this fixture's λ are
6020    /// likewise all pinned to that same wall, the gate is currently satisfied by
6021    /// railing rather than by the `M⊗S_t` symmetry it was written to protect —
6022    /// which means any fix that frees λ (i.e. the `#2579` fix, teaching
6023    /// `effective_df_floor_rho_upper_bounds` the joint bundle) will trip it for
6024    /// a reason that is not a regression in the symmetry. Prints only; never
6025    /// asserts a bound.
6026    #[test]
6027    fn zz_measure_2579_reference_class_gate_lambdas() {
6028        let td = tempdir().expect("tempdir");
6029        let dir = td.path();
6030        let (x, cls) = sample_classes(0, 300);
6031        let wall = gam_custom_family::EFFECTIVE_DF_CEILING.exp();
6032
6033        for (tag, name_map) in [
6034            ("abc", ["A", "B", "C"]),
6035            ("bca", ["B", "C", "A"]),
6036            ("cab", ["C", "A", "B"]),
6037        ] {
6038            let labels: Vec<String> = cls.iter().map(|&c| name_map[c].to_string()).collect();
6039            let train = dataset_xy(dir, tag, &x, &labels);
6040            let config = FitConfig::default();
6041            let model = fit_penalized_multinomial_formula(&MultinomialFitRequest {
6042                init_lambda: 1.0,
6043                max_iter: 60,
6044                tol: 1e-6,
6045                ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
6046            })
6047            .expect("multinomial formula fit must succeed");
6048
6049            // "Railed" is bit-equality with the wall, exactly as the
6050            // `EFFECTIVE_DF_CEILING` doc defines it.
6051            let railed = model.lambdas.iter().filter(|l| **l == wall).count();
6052            eprintln!(
6053                "#2579 gate-lambda probe [{tag}]: n_lambda={} railed_at_exp12={}/{} \
6054                 per_block={:?}",
6055                model.lambdas.len(),
6056                railed,
6057                model.lambdas.len(),
6058                model.lambdas_per_block
6059            );
6060            for (i, l) in model.lambdas.iter().enumerate() {
6061                eprintln!(
6062                    "#2579 gate-lambda probe [{tag}]:   lambda[{i}] = {l:.17e}  ln = {:.12}  \
6063                     railed = {}",
6064                    l.ln(),
6065                    *l == wall
6066                );
6067            }
6068        }
6069        eprintln!("#2579 gate-lambda probe: wall = exp(12) = {wall:.17e}");
6070    }
6071
6072    // gam#1587: now that the reference-symmetric centered `M⊗S_t` joint penalty
6073    // is wired through the custom-family outer REML loop (per-eval
6074    // `JointPenaltyBundle` + outer penalty_coords/logdet/operator), the
6075    // production multinomial fit is invariant to the arbitrary reference class,
6076    // so this guard runs by default (the opt-in skip attribute it carried while
6077    // the fix was pending is also forbidden by the build.rs ban-scanner). It is
6078    // an end-to-end fit guard (a handful of full softmax `y ~ s(x)` fits) —
6079    // slower than a unit test but a true production-path regression.
6080    #[test]
6081    fn multinomial_fit_is_invariant_to_reference_class_1587() {
6082        let td = tempdir().expect("tempdir");
6083        let dir = td.path();
6084        // The reference-class drift is STRUCTURAL (it does not shrink with n, see
6085        // the issue table), so a modest n exposes it just as cleanly as n=900
6086        // while keeping this an affordable CI guard.
6087        let (x, cls) = sample_classes(0, 300);
6088        let grid: Vec<f64> = (0..7).map(|i| -1.5 + 3.0 * (i as f64) / 6.0).collect();
6089
6090        // Three labelings that each make a DIFFERENT original class the baseline
6091        // (the class whose label sorts LAST is the reference K−1):
6092        //   ["A","B","C"] → ref = class 2
6093        //   ["B","C","A"] → ref = class 1
6094        //   ["C","A","B"] → ref = class 0
6095        let a = fit_predict_aligned(dir, "abc", &x, &cls, ["A", "B", "C"], &grid);
6096        let b = fit_predict_aligned(dir, "bca", &x, &cls, ["B", "C", "A"], &grid);
6097        let c = fit_predict_aligned(dir, "cab", &x, &cls, ["C", "A", "B"], &grid);
6098
6099        // Refitting the SAME labeling twice must agree to ~machine precision —
6100        // this isolates optimizer noise from the structural reference drift.
6101        let a2 = fit_predict_aligned(dir, "abc2", &x, &cls, ["A", "B", "C"], &grid);
6102        let refit_noise = max_abs_diff(&a, &a2);
6103        assert!(
6104            refit_noise < 1e-6,
6105            "refitting the same labeling must be deterministic (got {refit_noise:.3e})"
6106        );
6107
6108        let drift = max_abs_diff(&a, &b)
6109            .max(max_abs_diff(&a, &c))
6110            .max(max_abs_diff(&b, &c));
6111        assert!(
6112            drift < 1e-3,
6113            "predicted probabilities must be invariant to the reference class; \
6114             cross-labeling drift = {drift:.3e} (refit noise = {refit_noise:.3e})"
6115        );
6116    }
6117
6118    /// #2615 diagnostic (zz_measure): profile the REML criterion along ONE
6119    /// rank-1 null-space coordinate on the REAL penguins design, at the
6120    /// production fit's own rho, with and without the Jeffreys/Firth term.
6121    ///
6122    /// On non-separable linear data (`zz_measure_2615_nullspace_outer_gradient_fd`)
6123    /// REML has an interior optimum in this coordinate, so the effective-df
6124    /// floor is redundant there. Penguins is near-separable and the production
6125    /// fit rails every coordinate on its wall, so the question is what the
6126    /// criterion looks like when the wall is removed: an interior optimum
6127    /// (wall irrelevant, something else is wrong) or a monotone descent toward
6128    /// total collapse (marginal likelihood genuinely prefers killing the
6129    /// linear direction, which is the near-separation pathology the
6130    /// Jeffreys/Firth arm exists for). The second profile arms that term, so
6131    /// the two curves answer whether the floor is standing in for it.
6132    ///
6133    /// Prints only; never asserts a bound.
6134    #[test]
6135    fn zz_measure_2615_penguins_nullspace_criterion_profile() {
6136        const PENGUINS_CSV: &str = concat!(
6137            env!("CARGO_MANIFEST_DIR"),
6138            "/../../bench/datasets/penguins.csv"
6139        );
6140        let raw = match fs::read_to_string(PENGUINS_CSV) {
6141            Ok(text) => text,
6142            Err(err) => {
6143                eprintln!("#2615 penguins profile SKIPPED: {PENGUINS_CSV}: {err}");
6144                return;
6145            }
6146        };
6147        let mut lines = raw.lines();
6148        let header: Vec<&str> = lines
6149            .next()
6150            .expect("penguins header")
6151            .trim()
6152            .split(',')
6153            .collect();
6154        let idx = |name: &str| header.iter().position(|c| *c == name).expect("column");
6155        let (i_species, i_bl, i_bd, i_fl, i_bm) = (
6156            idx("species"),
6157            idx("bill_length_mm"),
6158            idx("bill_depth_mm"),
6159            idx("flipper_length_mm"),
6160            idx("body_mass_g"),
6161        );
6162        // The quality test's stride-3 TRAIN split, so this profiles the exact
6163        // design the reported numbers come from.
6164        let mut kept = 0usize;
6165        let mut csv =
6166            String::from("bill_length_mm,bill_depth_mm,flipper_length_mm,body_mass_g,species\n");
6167        for line in lines {
6168            let line = line.trim();
6169            if line.is_empty() {
6170                continue;
6171            }
6172            let f: Vec<&str> = line.split(',').collect();
6173            let num = |i: usize| f.get(i).and_then(|v| v.trim().parse::<f64>().ok());
6174            let (Some(bl), Some(bd), Some(fl), Some(bm)) =
6175                (num(i_bl), num(i_bd), num(i_fl), num(i_bm))
6176            else {
6177                continue;
6178            };
6179            let species = f[i_species].trim().trim_matches('"');
6180            if species.is_empty() || species == "NA" {
6181                continue;
6182            }
6183            if kept % 3 == 0 {
6184                kept += 1;
6185                continue; // held out by the quality test
6186            }
6187            kept += 1;
6188            writeln!(csv, "{bl},{bd},{fl},{bm},{species}").unwrap();
6189        }
6190        let td = tempdir().expect("tempdir");
6191        let path = td.path().join("penguins_train.csv");
6192        fs::write(&path, csv).expect("write penguins train csv");
6193        let cols: Vec<String> = [
6194            "bill_length_mm",
6195            "bill_depth_mm",
6196            "flipper_length_mm",
6197            "body_mass_g",
6198            "species",
6199        ]
6200        .iter()
6201        .map(|s| s.to_string())
6202        .collect();
6203        let train = load_dataset_projected(&path, &cols).expect("load penguins train");
6204
6205        let config = FitConfig::default();
6206        let request = MultinomialFitRequest {
6207            init_lambda: 1.0,
6208            max_iter: 100,
6209            tol: 1e-8,
6210            ..MultinomialFitRequest::new(
6211                &train,
6212                "species ~ s(bill_length_mm, k=10) + s(bill_depth_mm, k=10) \
6213                 + s(flipper_length_mm, k=10) + s(body_mass_g, k=10)",
6214                &config,
6215            )
6216        };
6217        let parts = penalized_multinomial_formula_parts(&request)
6218            .expect("production formula parts must build");
6219        let mut probe_options = parts.options.clone();
6220        probe_options.compute_covariance = false;
6221
6222        // The production fit's own rho (every coordinate ON its
6223        // effective-df-floor wall), term-major with K=3 per-class copies:
6224        //   t0 wiggle s1, t1 null s1, t2 wiggle s2, t3 null s2, ...
6225        const WALL: [f64; 8] = [
6226            8.498719656305731,
6227            -2.802688029911322,
6228            8.909882365327132,
6229            0.0313034971834482,
6230            8.69436831319073,
6231            -0.6515139527220641,
6232            8.897044748938537,
6233            -0.5549484578569235,
6234        ];
6235        let base: Vec<f64> = WALL.iter().flat_map(|&r| [r, r, r]).collect();
6236        assert_eq!(base.len(), 24, "4 smooths x 2 penalties x 3 classes");
6237
6238        // The null-space coordinate's own optimum is CONDITIONAL on how hard the
6239        // wiggliness half is smoothed: an unsmoothed range space can absorb a
6240        // linear trend over a bounded interval, which makes the null-space
6241        // direction redundant and its lambda uninformative. The production
6242        // search seeds at rho_wiggle ~ 1 and only reaches the wiggliness wall
6243        // late, so sweep that dependence explicitly with the null coordinate
6244        // held ON its wall -- the sign of g[3] there decides whether the
6245        // projector is right to pin it.
6246        eprintln!("#2615 penguins g[3] at the null-space WALL, as a function of rho_wiggle");
6247        for step in 0..8 {
6248            let rho_w = -4.0 + 2.0 * step as f64;
6249            let mut rho = base.clone();
6250            for (t, chunk) in rho.chunks_mut(3).enumerate() {
6251                if t % 2 == 0 {
6252                    for v in chunk {
6253                        *v = rho_w;
6254                    }
6255                }
6256            }
6257            let fam = parts
6258                .family
6259                .clone()
6260                .with_joint_initial_log_lambdas(rho.clone());
6261            match crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
6262                &fam,
6263                &parts.blocks,
6264                &probe_options,
6265                &ndarray::Array1::from(rho.clone()),
6266                gam_problem::EvalMode::ValueAndGradient,
6267            ) {
6268                Ok(d) => eprintln!(
6269                    "#2615   rho_wiggle={rho_w:+.1}  V={:.9e}  g[3]={:+.9e}  g[0]={:+.6e}",
6270                    d.objective, d.gradient[3], d.gradient[0]
6271                ),
6272                Err(e) => eprintln!(
6273                    "#2615   rho_wiggle={rho_w:+.1}  REFUSED: {}",
6274                    format!("{e}").chars().take(200).collect::<String>()
6275                ),
6276            }
6277        }
6278
6279        for firth in [false, true] {
6280            eprintln!(
6281                "#2615 penguins null-space profile (coordinate 3 = null space of s(bill_length), \
6282                 jeffreys={firth})"
6283            );
6284            for step in 0..12 {
6285                let rho_n = -8.0 + 2.0 * step as f64;
6286                let mut rho = base.clone();
6287                for c in 3..6 {
6288                    rho[c] = rho_n;
6289                }
6290                let mut fam = parts
6291                    .family
6292                    .clone()
6293                    .with_joint_initial_log_lambdas(rho.clone());
6294                if firth {
6295                    fam = fam.with_joint_jeffreys_term(true);
6296                }
6297                match crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
6298                    &fam,
6299                    &parts.blocks,
6300                    &probe_options,
6301                    &ndarray::Array1::from(rho.clone()),
6302                    gam_problem::EvalMode::ValueAndGradient,
6303                ) {
6304                    Ok(d) => eprintln!(
6305                        "#2615   rho_null={rho_n:+.1}  V={:.9e}  g[3]={:+.6e}  \
6306                         g[0]={:+.6e}  inner_conv={}",
6307                        d.objective, d.gradient[3], d.gradient[0], d.inner_converged
6308                    ),
6309                    Err(e) => eprintln!(
6310                        "#2615   rho_null={rho_n:+.1}  REFUSED: {}",
6311                        format!("{e}").chars().take(200).collect::<String>()
6312                    ),
6313                }
6314            }
6315        }
6316    }
6317
6318    /// #2612 discriminator (zz_measure): does the outer REML criterion's ANALYTIC
6319    /// gradient agree with the criterion it reports, once the Jeffreys/Firth term
6320    /// is armed?
6321    ///
6322    /// The armed refit fails with `line_search=StepSizeTooSmall` — "the direction
6323    /// descended but no step improved the objective" — at a point whose analytic
6324    /// outer Hessian is indefinite. A correct gradient makes that impossible: a
6325    /// short enough step along `−g` must decrease a differentiable objective. So
6326    /// either the criterion is not differentiable there or the gradient is not
6327    /// its gradient, and central finite differences separate the two.
6328    ///
6329    /// Both criteria are profiled at the SAME rho so the comparison is
6330    /// unbiased-vs-armed rather than point-vs-point.
6331    ///
6332    /// Prints only; never asserts a bound.
6333    #[test]
6334    fn zz_measure_2612_penguins_firth_outer_gradient_fd() {
6335        let td = tempdir().expect("tempdir");
6336        let Some(train) = penguins_stride3_train(&td) else {
6337            eprintln!("#2612 penguins outer-gradient FD SKIPPED: dataset unavailable");
6338            return;
6339        };
6340        let config = FitConfig::default();
6341        let request = MultinomialFitRequest {
6342            init_lambda: 1.0,
6343            max_iter: 100,
6344            tol: 1e-8,
6345            ..MultinomialFitRequest::new(
6346                &train,
6347                "species ~ s(bill_length_mm, k=10) + s(bill_depth_mm, k=10) \
6348                 + s(flipper_length_mm, k=10) + s(body_mass_g, k=10)",
6349                &config,
6350            )
6351        };
6352        let parts = penalized_multinomial_formula_parts(&request)
6353            .expect("production formula parts must build");
6354        let mut probe_options = parts.options.clone();
6355        probe_options.compute_covariance = false;
6356
6357        // The rho the Jeffreys-armed refit stalls at (its own reported
6358        // `last_evaluated_rho`), so the FD is taken where the line search failed.
6359        const STALL_RHO: [f64; 24] = [
6360            -0.03913583685376737,
6361            4.944294591230679,
6362            8.498719656305731,
6363            -2.802688029911322,
6364            -2.802688029911322,
6365            -2.802688029911322,
6366            8.909882365327132,
6367            8.909882365327132,
6368            8.909882365327132,
6369            -1.0331096452357313,
6370            0.0313034971834482,
6371            -8.433811582477187,
6372            8.69436831319073,
6373            8.69436831319073,
6374            8.69436831319073,
6375            -0.6515139527220641,
6376            -0.6515139527220641,
6377            -0.6515139527220641,
6378            8.897044748938537,
6379            6.638496386409038,
6380            6.954188147738595,
6381            -0.5549484578569235,
6382            -8.433811582477187,
6383            -0.9368158566294944,
6384        ];
6385
6386        for armed in [false, true] {
6387            let rho = ndarray::Array1::from(STALL_RHO.to_vec());
6388            let build = |rho: &ndarray::Array1<f64>| {
6389                let mut fam = parts
6390                    .family
6391                    .clone()
6392                    .with_joint_initial_log_lambdas(rho.to_vec());
6393                if armed {
6394                    fam = fam.with_joint_jeffreys_term(true);
6395                }
6396                fam
6397            };
6398            let evaluate = |rho: &ndarray::Array1<f64>, mode| {
6399                crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
6400                    &build(rho),
6401                    &parts.blocks,
6402                    &probe_options,
6403                    rho,
6404                    mode,
6405                )
6406            };
6407            let base = match evaluate(&rho, gam_problem::EvalMode::ValueAndGradient) {
6408                Ok(d) => d,
6409                Err(e) => {
6410                    eprintln!(
6411                        "#2612 FD (jeffreys={armed}) base REFUSED: {}",
6412                        format!("{e}").chars().take(300).collect::<String>()
6413                    );
6414                    continue;
6415                }
6416            };
6417            eprintln!(
6418                "#2612 FD (jeffreys={armed}) V={:.12e} inner_conv={} |g|inf={:.3e}",
6419                base.objective,
6420                base.inner_converged,
6421                base.gradient.iter().fold(0.0_f64, |a, v| a.max(v.abs())),
6422            );
6423            // Coordinates the stall report named as carrying the residual
6424            // gradient, plus one wiggliness coordinate as a control.
6425            for k in [0usize, 1, 4, 9, 10, 11, 20, 23] {
6426                let mut worst = String::new();
6427                for h in [1e-3_f64, 1e-2] {
6428                    let mut plus = rho.clone();
6429                    plus[k] += h;
6430                    let mut minus = rho.clone();
6431                    minus[k] -= h;
6432                    let (vp, vm) = (
6433                        evaluate(&plus, gam_problem::EvalMode::ValueOnly),
6434                        evaluate(&minus, gam_problem::EvalMode::ValueOnly),
6435                    );
6436                    match (vp, vm) {
6437                        (Ok(p), Ok(m)) => {
6438                            let fd = (p.objective - m.objective) / (2.0 * h);
6439                            let g = base.gradient[k];
6440                            let denom = g.abs().max(fd.abs()).max(1e-12);
6441                            write!(
6442                                worst,
6443                                "  h={h:.0e}: fd={fd:+.6e} analytic={g:+.6e} \
6444                                 rel={:.2e} inner=({},{})",
6445                                (fd - g).abs() / denom,
6446                                p.inner_converged,
6447                                m.inner_converged
6448                            )
6449                            .unwrap();
6450                        }
6451                        (p, m) => {
6452                            write!(
6453                                worst,
6454                                "  h={h:.0e}: REFUSED (plus_ok={}, minus_ok={})",
6455                                p.is_ok(),
6456                                m.is_ok()
6457                            )
6458                            .unwrap();
6459                        }
6460                    }
6461                }
6462                eprintln!("#2612 FD (jeffreys={armed}) coord {k:2}:{worst}");
6463            }
6464        }
6465    }
6466
6467    /// #2612 discriminator (zz_measure): at the point the armed refit now stops
6468    /// at, is the negative curvature the terminal certificate reports a property
6469    /// of the CRITERION, or of the analytic Hessian that reports it?
6470    ///
6471    /// With the mode-response operator fixed the armed refit reaches
6472    /// `|Pg| = 1.276e-3` against `bound = 2.290e-3` and BFGS terminates on its
6473    /// gradient tolerance — stationarity is certified. What refuses is the
6474    /// second-order conjunct: `interior lambda_min = -1.128e-2` against
6475    /// `gradient_floor = 9.616e-4`. The outer Hessian for a Jeffreys-armed family
6476    /// is knowingly incomplete (the `D2_beta H_Phi` term needs third directional
6477    /// derivatives no family exposes), so that verdict has two readings and they
6478    /// call for opposite work:
6479    ///
6480    ///   * the criterion really does fall along that eigenvector, and the SEARCH
6481    ///     stopped early at a saddle — the optimizer owes an escape;
6482    ///   * the criterion rises along it, and the CERTIFICATE is reading a matrix
6483    ///     that is not the criterion's curvature — the Hessian owes a repair.
6484    ///
6485    /// The discriminator does not need an exact Hessian: walk the criterion
6486    /// itself along the reported minimum eigenvector and read whether the value
6487    /// goes down. Prints only; never asserts a bound.
6488    #[test]
6489    fn zz_measure_2612_penguins_terminal_negative_curvature_is_real() {
6490        let td = tempdir().expect("tempdir");
6491        let Some(train) = penguins_stride3_train(&td) else {
6492            eprintln!("#2612 penguins curvature probe SKIPPED: dataset unavailable");
6493            return;
6494        };
6495        let config = FitConfig::default();
6496        let request = MultinomialFitRequest {
6497            init_lambda: 1.0,
6498            max_iter: 100,
6499            tol: 1e-8,
6500            ..MultinomialFitRequest::new(
6501                &train,
6502                "species ~ s(bill_length_mm, k=10) + s(bill_depth_mm, k=10) \
6503                 + s(flipper_length_mm, k=10) + s(body_mass_g, k=10)",
6504                &config,
6505            )
6506        };
6507        let parts = penalized_multinomial_formula_parts(&request)
6508            .expect("production formula parts must build");
6509        let mut probe_options = parts.options.clone();
6510        probe_options.compute_covariance = false;
6511
6512        // The rho the armed refit terminates at, from its own refusal report.
6513        const TERMINAL_RHO: [f64; 24] = [
6514            0.5479760615083404,
6515            4.953765284885532,
6516            8.498719656305731,
6517            -2.802688029911322,
6518            -2.995598248166518,
6519            -2.802688029911322,
6520            8.909882365327132,
6521            8.909882365327132,
6522            1.9116946378497857,
6523            -1.058535206379601,
6524            0.0313034971834482,
6525            -8.433811582477187,
6526            8.69436831319073,
6527            8.69436831319073,
6528            8.69436831319073,
6529            -0.6515139527220641,
6530            -0.6515139527220641,
6531            -0.6515139527220641,
6532            8.897044748938537,
6533            6.570949527783591,
6534            7.234627505361151,
6535            -0.5549484578569235,
6536            -8.433811582477187,
6537            -0.9887101040185263,
6538        ];
6539        let rho = ndarray::Array1::from(TERMINAL_RHO.to_vec());
6540        let evaluate = |rho: &ndarray::Array1<f64>, mode| {
6541            let fam = parts
6542                .family
6543                .clone()
6544                .with_joint_jeffreys_term(true)
6545                .with_joint_initial_log_lambdas(rho.to_vec());
6546            crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
6547                &fam,
6548                &parts.blocks,
6549                &probe_options,
6550                rho,
6551                mode,
6552            )
6553        };
6554
6555        let base = match evaluate(&rho, gam_problem::EvalMode::ValueGradientHessian) {
6556            Ok(d) => d,
6557            Err(e) => {
6558                eprintln!(
6559                    "#2612 curvature probe base REFUSED: {}",
6560                    format!("{e}").chars().take(300).collect::<String>()
6561                );
6562                return;
6563            }
6564        };
6565        let Some(hessian) = base.outer_hessian.clone() else {
6566            eprintln!("#2612 curvature probe: no analytic outer Hessian was materialized");
6567            return;
6568        };
6569        eprintln!(
6570            "#2612 curvature probe: V={:.12e} |g|inf={:.3e} inner_conv={}",
6571            base.objective,
6572            base.gradient.iter().fold(0.0_f64, |a, v| a.max(v.abs())),
6573            base.inner_converged,
6574        );
6575
6576        // The certificate's curvature verdict is taken on the INTERIOR sub-block
6577        // — the free (un-railed) coordinates only — so the direction to walk is
6578        // that block's minimum eigenvector, embedded with zeros on the railed
6579        // ones. Walking the FULL Hessian's minimum eigenvector instead measures
6580        // a direction the box blocks, and reads back the criterion's first-order
6581        // slope against the rail rather than its curvature on the face.
6582        //
6583        // The railed set is the one the refit's own refusal reported.
6584        const RAILED: [usize; 18] = [
6585            2, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 16, 17, 18, 21, 22, 23,
6586        ];
6587        let free: Vec<usize> = (0..hessian.nrows())
6588            .filter(|k| !RAILED.contains(k))
6589            .collect();
6590        let mut sub = ndarray::Array2::<f64>::zeros((free.len(), free.len()));
6591        for (i, &ri) in free.iter().enumerate() {
6592            for (j, &rj) in free.iter().enumerate() {
6593                sub[[i, j]] = 0.5 * (hessian[[ri, rj]] + hessian[[rj, ri]]);
6594            }
6595        }
6596        eprintln!(
6597            "#2612 curvature probe: free coordinates {free:?}  gradient there = {:?}",
6598            free.iter()
6599                .map(|&k| format!("{:+.4e}", base.gradient[k]))
6600                .collect::<Vec<_>>()
6601        );
6602        let (evals, evecs) = match sub.eigh(faer::Side::Lower) {
6603            Ok(pair) => pair,
6604            Err(e) => {
6605                eprintln!("#2612 curvature probe: interior-block eigendecomposition failed: {e}");
6606                return;
6607            }
6608        };
6609        let mut order: Vec<usize> = (0..evals.len()).collect();
6610        order.sort_by(|a, b| evals[*a].partial_cmp(&evals[*b]).expect("finite"));
6611        eprintln!(
6612            "#2612 curvature probe: interior sub-block spectrum = {:?}",
6613            order
6614                .iter()
6615                .map(|&i| format!("{:.4e}", evals[i]))
6616                .collect::<Vec<_>>()
6617        );
6618
6619        // Walk the criterion along the interior block's minimum eigenvector. A
6620        // genuine negative-curvature direction lowers the value on BOTH sides.
6621        let mut direction = ndarray::Array1::<f64>::zeros(hessian.nrows());
6622        for (i, &k) in free.iter().enumerate() {
6623            direction[k] = evecs[[i, order[0]]];
6624        }
6625        for step in [1e-3_f64, 1e-2, 1e-1, 5e-1] {
6626            let mut readings = Vec::new();
6627            for sign in [1.0_f64, -1.0] {
6628                let trial = &rho + &(&direction * (sign * step));
6629                match evaluate(&trial, gam_problem::EvalMode::ValueOnly) {
6630                    Ok(d) => readings.push(format!(
6631                        "{sign:+.0}: dV={:+.6e} inner_conv={}",
6632                        d.objective - base.objective,
6633                        d.inner_converged
6634                    )),
6635                    Err(e) => readings.push(format!(
6636                        "{sign:+.0}: REFUSED {}",
6637                        format!("{e}").chars().take(120).collect::<String>()
6638                    )),
6639                }
6640            }
6641            let predicted = 0.5 * evals[order[0]] * step * step;
6642            eprintln!(
6643                "#2612 curvature probe: step={step:.0e} predicted dV={predicted:+.6e}  {}",
6644                readings.join("   ")
6645            );
6646        }
6647
6648        // The SHARPER instrument, and the one the verdict is actually about.
6649        //
6650        // The value walk above second-differences the criterion, so its noise is
6651        // `2·ε_V/h²`: at `λ_min ~ 1e-3` and `h = 1e-3` the signal is `5e-10`,
6652        // which is under any plausible `ε_V`. Central differences of the
6653        // ANALYTIC gradient are a FIRST difference of an exact quantity, so the
6654        // same `h` buys two more orders, and the quantity they produce —
6655        // `vᵀ J(g) v` — is exactly what `vᵀ H v` claims to be. Any disagreement
6656        // between the two is `‖δH‖₂` along the direction that decided, measured
6657        // rather than assumed (SPEC 2 keeps this in a test, which is where it
6658        // belongs: it is evidence about the assembly, not a production
6659        // derivative).
6660        //
6661        // The armed Hessian is KNOWN to be incomplete — `D²_β H_Φ[−v_l, −v_k]`
6662        // is not folded in, because `H_Φ` is a divided-difference object whose
6663        // first β-derivative already consumes the family's second directional
6664        // derivatives and a second would need the third (see
6665        // `JeffreysHphiAwareJointDerivatives`). This measures how much that
6666        // costs on the direction the certificate refuses on, at production
6667        // scale, rather than on the seconds-scale synthetic fixture.
6668        let analytic_curvature = evals[order[0]];
6669        let mut measured_curvatures: Vec<f64> = Vec::new();
6670        for h in [1e-4_f64, 1e-3, 1e-2] {
6671            let plus = &rho + &(&direction * h);
6672            let minus = &rho - &(&direction * h);
6673            let (up, down) = (
6674                evaluate(&plus, gam_problem::EvalMode::ValueAndGradient),
6675                evaluate(&minus, gam_problem::EvalMode::ValueAndGradient),
6676            );
6677            match (up, down) {
6678                (Ok(up), Ok(down)) => {
6679                    let measured = (&up.gradient - &down.gradient).dot(&direction) / (2.0 * h);
6680                    let gap = (analytic_curvature - measured).abs();
6681                    eprintln!(
6682                        "#2612 curvature probe: h={h:.0e}  v'Hv(analytic)={analytic_curvature:+.6e}  \
6683                         v'J(g)v(measured)={measured:+.6e}  |gap|={gap:.6e}  \
6684                         gap/|analytic|={:.3e}  inner_conv={}/{}",
6685                        gap / analytic_curvature.abs().max(f64::MIN_POSITIVE),
6686                        up.inner_converged,
6687                        down.inner_converged,
6688                    );
6689                    measured_curvatures.push(measured);
6690                }
6691                (up, down) => eprintln!(
6692                    "#2612 curvature probe: h={h:.0e} directional gradient difference \
6693                     unavailable (+ok={} -ok={})",
6694                    up.is_ok(),
6695                    down.is_ok(),
6696                ),
6697            }
6698        }
6699        // The one BAR this file carries, and it is the file's own title: is the
6700        // reported negative curvature REAL?
6701        //
6702        // Everything above prints. This asserts the single thing the terminal
6703        // certificate's whole verdict rests on — that the analytic Hessian and
6704        // the criterion agree about the SIGN of the direction that decides —
6705        // at production scale, where the armed gate in
6706        // `multinomial_jeffreys_outer_gradient_fd_2612` cannot reach (it runs a
6707        // seconds-scale synthetic fixture).
6708        //
6709        // Measured here: analytic `-6.709810e-5` against `-9.248844e-5`, stable
6710        // to five digits across `h` spanning two decades, so the `3.784e-1`
6711        // relative gap is the omitted `D²_β H_Φ` term and not FD noise. That gap
6712        // EXCEEDS the `0.25` bar the armed gate applies to `λ_min` on its own
6713        // fixture, which is the finding; the sign, which is what the certificate
6714        // spends, holds. A sign flip is the #2665 failure (`-1721.5` analytic
6715        // against `+121.6` measured), and it is what this refuses to let recur
6716        // unnoticed.
6717        assert!(
6718            !measured_curvatures.is_empty(),
6719            "no directional gradient difference could be taken, so the sign bar below was never \
6720             evaluated -- that is absence of coverage, not a pass"
6721        );
6722        for measured in &measured_curvatures {
6723            assert_eq!(
6724                measured.is_sign_negative(),
6725                analytic_curvature.is_sign_negative(),
6726                "the analytic curvature {analytic_curvature:+.6e} on the direction the \
6727                 certificate judges disagrees in SIGN with the criterion's own \
6728                 {measured:+.6e}. The terminal certificate's entire second-order verdict is \
6729                 that sign (#2665)."
6730            );
6731        }
6732    }
6733
6734    /// The quality arm's stride-3 penguins TRAIN split, materialized through the
6735    /// production dataset loader. `None` when the checked-in CSV is unavailable.
6736    fn penguins_stride3_train(td: &tempfile::TempDir) -> Option<gam_data::EncodedDataset> {
6737        const PENGUINS_CSV: &str = concat!(
6738            env!("CARGO_MANIFEST_DIR"),
6739            "/../../bench/datasets/penguins.csv"
6740        );
6741        let raw = match fs::read_to_string(PENGUINS_CSV) {
6742            Ok(text) => text,
6743            Err(err) => {
6744                eprintln!("penguins CSV unavailable: {PENGUINS_CSV}: {err}");
6745                return None;
6746            }
6747        };
6748        let mut lines = raw.lines();
6749        let header: Vec<&str> = lines
6750            .next()
6751            .expect("penguins header")
6752            .trim()
6753            .split(',')
6754            .collect();
6755        let idx = |name: &str| header.iter().position(|c| *c == name).expect("column");
6756        let (i_species, i_bl, i_bd, i_fl, i_bm) = (
6757            idx("species"),
6758            idx("bill_length_mm"),
6759            idx("bill_depth_mm"),
6760            idx("flipper_length_mm"),
6761            idx("body_mass_g"),
6762        );
6763        let mut kept = 0usize;
6764        let mut csv =
6765            String::from("bill_length_mm,bill_depth_mm,flipper_length_mm,body_mass_g,species\n");
6766        for line in lines {
6767            let line = line.trim();
6768            if line.is_empty() {
6769                continue;
6770            }
6771            let f: Vec<&str> = line.split(',').collect();
6772            let num = |i: usize| f.get(i).and_then(|v| v.trim().parse::<f64>().ok());
6773            let (Some(bl), Some(bd), Some(fl), Some(bm)) =
6774                (num(i_bl), num(i_bd), num(i_fl), num(i_bm))
6775            else {
6776                continue;
6777            };
6778            let species = f[i_species].trim().trim_matches('"');
6779            if species.is_empty() || species == "NA" {
6780                continue;
6781            }
6782            if kept % 3 == 0 {
6783                kept += 1;
6784                continue; // held out by the quality test
6785            }
6786            kept += 1;
6787            writeln!(csv, "{bl},{bd},{fl},{bm},{species}").unwrap();
6788        }
6789        let path = td.path().join("penguins_train.csv");
6790        fs::write(&path, csv).expect("write penguins train csv");
6791        let cols: Vec<String> = [
6792            "bill_length_mm",
6793            "bill_depth_mm",
6794            "flipper_length_mm",
6795            "body_mass_g",
6796            "species",
6797        ]
6798        .iter()
6799        .map(|s| s.to_string())
6800        .collect();
6801        Some(load_dataset_projected(&path, &cols).expect("load penguins train"))
6802    }
6803
6804    /// #2615 diagnostic (zz_measure): does the outer REML criterion SEE the
6805    /// rank-1 null-space smoothing coordinate at all?
6806    ///
6807    /// The production penguins fit selects EVERY joint λ exactly on its
6808    /// effective-df-floor wall, and the raw outer gradient prints `+0.0000` at
6809    /// every rank-1 (null-space) coordinate while the rank-8 (wiggliness)
6810    /// coordinates carry |g| ≈ 1.9. If that near-zero is the true derivative,
6811    /// the criterion is genuinely flat in the linear direction's λ and the
6812    /// df floor is making a modelling choice REML declines to make. If central
6813    /// FD disagrees, the analytic outer gradient is desynced from its own
6814    /// criterion on exactly the coordinate #2608/#2612 had to pin by hand.
6815    ///
6816    /// `sample_classes` draws a PURELY LINEAR softmax, so the whole signal
6817    /// lives in the null space of the wiggliness penalty — the same geometry
6818    /// as penguins, at a fixture cost. Prints only; never asserts a bound.
6819    #[test]
6820    fn zz_measure_2615_nullspace_outer_gradient_fd() {
6821        let td = tempdir().expect("tempdir");
6822        let dir = td.path();
6823        let (x, cls) = sample_classes(0, 300);
6824        let labels: Vec<String> = cls
6825            .iter()
6826            .map(|&c| ["A", "B", "C"][c].to_string())
6827            .collect();
6828        let train = dataset_xy(dir, "fd2615", &x, &labels);
6829        let config = FitConfig::default();
6830        let request = MultinomialFitRequest {
6831            init_lambda: 1.0,
6832            max_iter: 60,
6833            tol: 1e-6,
6834            ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
6835        };
6836        let parts = penalized_multinomial_formula_parts(&request)
6837            .expect("production formula parts must build");
6838        let mut probe_options = parts.options.clone();
6839        probe_options.compute_covariance = false;
6840
6841        // Coordinate layout: joint specs are emitted term-major, K per-class
6842        // specs per term, so with one smooth (wiggliness + null space) and
6843        // K = 3 this is [w,w,w, n,n,n].
6844        let eval_at = |rho_vec: &[f64]| -> (f64, ndarray::Array1<f64>, bool) {
6845            let fam = parts
6846                .family
6847                .clone()
6848                .with_joint_initial_log_lambdas(rho_vec.to_vec());
6849            let diagnostics =
6850                crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
6851                    &fam,
6852                    &parts.blocks,
6853                    &probe_options,
6854                    &ndarray::Array1::from(rho_vec.to_vec()),
6855                    gam_problem::EvalMode::ValueAndGradient,
6856                )
6857                .expect("labeled outer evaluation");
6858            (
6859                diagnostics.objective,
6860                diagnostics.gradient,
6861                diagnostics.inner_converged,
6862            )
6863        };
6864
6865        // The wiggliness coordinate is held where the production fit puts it
6866        // (hard against its own wall); only the null-space coordinate moves.
6867        const RHO_WIGGLE: f64 = 8.0;
6868        let h = 1.0e-3;
6869        eprintln!("#2615 null-space profile (rho_wiggle={RHO_WIGGLE}, one smooth, K=3, n=300)");
6870        for step in 0..11 {
6871            let rho_n = -8.0 + 2.0 * step as f64;
6872            let rho: Vec<f64> = vec![RHO_WIGGLE, RHO_WIGGLE, RHO_WIGGLE, rho_n, rho_n, rho_n];
6873            let (v, g, conv) = eval_at(&rho);
6874            let mut plus = rho.clone();
6875            let mut minus = rho.clone();
6876            plus[3] += h;
6877            minus[3] -= h;
6878            let (vp, _, _) = eval_at(&plus);
6879            let (vm, _, _) = eval_at(&minus);
6880            let fd = (vp - vm) / (2.0 * h);
6881            eprintln!(
6882                "#2615   rho_null={rho_n:+.1}  V={v:.9e}  g[3]analytic={:+.6e}                   g[3]fd={fd:+.6e}  ratio={:+.3e}  g[0]analytic={:+.6e}  inner_conv={conv}",
6883                g[3],
6884                if fd.abs() > 0.0 { g[3] / fd } else { f64::NAN },
6885                g[0],
6886            );
6887        }
6888    }
6889
6890    /// #2349 diagnostic (zz_measure): finite-difference the OUTER REML
6891    /// criterion of the EXACT production multinomial objective at the refusal
6892    /// checkpoint from MSI job 13390650. The certificate there claimed
6893    /// `|Pg| = 2.047` against a bound of `2.697e-3` after the optimizer
6894    /// stalled — if the fixed-ρ criterion's central FD gradient at that same
6895    /// checkpoint is comparably large, the surface is genuinely non-stationary
6896    /// and the stall is the optimizer's; if it is orders of magnitude smaller,
6897    /// the analytic outer gradient is desynced from the criterion (the
6898    /// coalesced overlapping joint-family pseudo-logdet is the suspect).
6899    /// Prints only; never asserts a bound.
6900    #[test]
6901    fn zz_measure_2349_outer_gradient_fd_at_refusal_checkpoint() {
6902        let td = tempdir().expect("tempdir");
6903        let dir = td.path();
6904        let (x, cls) = sample_classes(0, 300);
6905        let labels: Vec<String> = cls
6906            .iter()
6907            .map(|&c| ["A", "B", "C"][c].to_string())
6908            .collect();
6909        let train = dataset_xy(dir, "fd2349", &x, &labels);
6910        let config = FitConfig::default();
6911        let request = MultinomialFitRequest {
6912            init_lambda: 1.0,
6913            max_iter: 60,
6914            tol: 1e-6,
6915            ..MultinomialFitRequest::new(&train, "y ~ s(x)", &config)
6916        };
6917        let parts = penalized_multinomial_formula_parts(&request)
6918            .expect("production formula parts must build");
6919        // Unbiased-arm refusal checkpoint (MSI job 13390650, #2349): the
6920        // 6-coordinate joint ρ = 2 terms × 3 per-class λ, term-major.
6921        let rho_star = [
6922            6.50584039279757,
6923            -1.6183906983083074,
6924            5.922109861708934,
6925            -0.5810545109816936,
6926            -0.4894709703255621,
6927            1.299144316808675,
6928        ];
6929        // The criterion probe needs no posterior covariance — and at this
6930        // checkpoint it CANNOT have one: the joint precision H + S_λ is
6931        // measurably singular there (1 flat direction, the first hard datum
6932        // this gate produced), so the covariance factorization honestly
6933        // refuses. The REML criterion value is still well-defined through the
6934        // pseudo-logdet.
6935        let mut probe_options = parts.options.clone();
6936        probe_options.compute_covariance = false;
6937        eprintln!(
6938            "#2349 gate state: use_remlobjective={} (RidgedQuadraticReml default => \
6939             logdet_h/logdet_s included in the fixed-lambda score iff this is true)",
6940            probe_options.use_remlobjective
6941        );
6942        let v_at_with = |rho: &[f64], use_reml: bool| -> f64 {
6943            let fam = parts
6944                .family
6945                .clone()
6946                .with_joint_initial_log_lambdas(rho.to_vec());
6947            let mut opts = probe_options.clone();
6948            opts.use_remlobjective = use_reml;
6949            let fit = crate::custom_family::fit_custom_family_fixed_log_lambdas(
6950                &fam,
6951                &parts.blocks,
6952                &opts,
6953                None,
6954            )
6955            .expect("fixed-lambda inner solve at the checkpoint must converge");
6956            fit.reml_score()
6957                .expect("a fixed-lambda custom-family solve reports its criterion")
6958        };
6959        let v_plain = v_at_with(&rho_star, false);
6960        let v_laml = v_at_with(&rho_star, true);
6961        eprintln!(
6962            "#2349 V(rho*): plain(penalized NLL)={v_plain:.9e} \
6963             laml(+0.5logdetH-0.5logdetS)={v_laml:.9e} logdet_pair={:.9e} \
6964             (the refusal reported final objective 2.687403e2 at this checkpoint — \
6965             whichever variant matches IS the outer criterion)",
6966            v_laml - v_plain
6967        );
6968        let outer_uses_laml = (v_laml - 2.687403e2).abs() < (v_plain - 2.687403e2).abs();
6969        let v_at = |rho: &[f64]| -> f64 { v_at_with(rho, outer_uses_laml) };
6970        // Term-for-term decomposition of the fixed-ρ score so the ~12.5 offset
6971        // from the outer criterion can be attributed to a specific missing
6972        // term. A ρ-CONSTANT offset leaves the FD gradient verdict intact; a
6973        // missing ½·log|S_λ|₊ (strongly ρ-dependent, O(1) gradient per
6974        // coordinate) would contaminate it.
6975        {
6976            let fam = parts
6977                .family
6978                .clone()
6979                .with_joint_initial_log_lambdas(rho_star.to_vec());
6980            let fit = crate::custom_family::fit_custom_family_fixed_log_lambdas(
6981                &fam,
6982                &parts.blocks,
6983                &probe_options,
6984                None,
6985            )
6986            .expect("fixed-lambda decomposition fit at the checkpoint");
6987            eprintln!(
6988                "#2349 decompose: reml_score={:.9e} penalized_objective={:.9e} \
6989                 log_likelihood={:.9e} deviance={:.9e}",
6990                fit.reml_score().unwrap_or(f64::NAN),
6991                fit.penalized_objective().unwrap_or(f64::NAN),
6992                fit.log_likelihood,
6993                fit.deviance
6994            );
6995        }
6996        let h = 1.0e-3;
6997        let mut grad_fd = [0.0_f64; 6];
6998        for s in 0..6 {
6999            let mut plus = rho_star;
7000            plus[s] += h;
7001            let mut minus = rho_star;
7002            minus[s] -= h;
7003            grad_fd[s] = (v_at(&plus) - v_at(&minus)) / (2.0 * h);
7004            eprintln!("#2349 FD dV/drho[{s}] = {:+.6e}", grad_fd[s]);
7005        }
7006        let norm = grad_fd.iter().map(|g| g * g).sum::<f64>().sqrt();
7007        eprintln!(
7008            "#2349 |FD grad| = {norm:.6e} on the {} criterion \
7009             (certificate claimed |Pg|=2.047e0, bound 2.697e-3)",
7010            if outer_uses_laml {
7011                "LAML"
7012            } else {
7013                "plain penalized-NLL"
7014            }
7015        );
7016
7017        // ── Warm-start stall isolation (#2349, round 3) ────────────────────
7018        //
7019        // The unbiased-arm refusal recorded objective 268.740 at its OWN best
7020        // iterate ρ*, while a cold fixed-λ solve at the same ρ* reaches
7021        // 256.166 — the outer's warm-started inner state sat ~12.6 above the
7022        // mode of a CONVEX objective while claiming convergence. If that stall
7023        // is real it must reproduce in isolation: warm-start the fixed-λ solve
7024        // at ρ* from the mode of a DISTANT ρ (the outer's actual eval pattern)
7025        // and compare against the cold value. A warm-started value ≫ cold with
7026        // an Ok return is the minimal repro of a lying inner certificate; an
7027        // Err is the honest refusal; a matching value clears the inner solver
7028        // and points the 12.6 gap at the outer eval bookkeeping instead.
7029        for delta in [2.0_f64, -2.0] {
7030            let rho_far: Vec<f64> = rho_star.iter().map(|r| r + delta).collect();
7031            let fam_far = parts
7032                .family
7033                .clone()
7034                .with_joint_initial_log_lambdas(rho_far.clone());
7035            let far_fit = crate::custom_family::fit_custom_family_fixed_log_lambdas(
7036                &fam_far,
7037                &parts.blocks,
7038                &probe_options,
7039                None,
7040            )
7041            .expect("cold fixed-lambda solve at the far point");
7042            let far_beta: Vec<f64> = far_fit
7043                .block_states
7044                .iter()
7045                .flat_map(|bs| bs.beta.iter().copied())
7046                .collect();
7047            let block_cols: Vec<usize> = parts.blocks.iter().map(|s| s.design.ncols()).collect();
7048            let warm = crate::custom_family::CustomFamilyWarmStart::from_cached_beta(
7049                &block_cols,
7050                &ndarray::Array1::from(far_beta),
7051            )
7052            .expect("warm start from far-point mode");
7053            let fam_star = parts
7054                .family
7055                .clone()
7056                .with_joint_initial_log_lambdas(rho_star.to_vec());
7057            match crate::custom_family::fit_custom_family_fixed_log_lambdas(
7058                &fam_star,
7059                &parts.blocks,
7060                &probe_options,
7061                Some(&warm),
7062            ) {
7063                Ok(fit) => eprintln!(
7064                    "#2349 warm-from(delta={delta:+.1}): V={:.9e} (cold {:.9e}, refusal 2.687403e2) \
7065                     gap_to_cold={:+.3e}",
7066                    fit.reml_score().unwrap_or(f64::NAN),
7067                    v_laml,
7068                    fit.reml_score().unwrap_or(f64::NAN) - v_laml
7069                ),
7070                Err(e) => eprintln!(
7071                    "#2349 warm-from(delta={delta:+.1}): inner REFUSED honestly: {}",
7072                    format!("{e}").chars().take(220).collect::<String>()
7073                ),
7074            }
7075        }
7076
7077        // ── Round 5: the LABELED production evaluator at ρ* + FD gate ─────
7078        //
7079        // Round 4's hyper-evaluator saw no outer coordinates (grad=[], and its
7080        // objective 245.99 matched an unpenalized solve): the joint λs are
7081        // OUTER coordinates only through the labeled layout. This round calls
7082        // the exact production functional (canonicalize → pulled-back joint
7083        // specs → labeled layout → outerobjectivegradienthessian_labeled) at
7084        // the checkpoint. Its objective settles whether the refusal's 268.740
7085        // is that functional's value (and the 12.574 a criterion difference vs
7086        // the fixed-λ LAML) — and the analytic-vs-FD comparison per coordinate
7087        // is the obj↔grad desync gate (issue suspect 1) on the REAL surface.
7088        {
7089            let fam = parts
7090                .family
7091                .clone()
7092                .with_joint_initial_log_lambdas(rho_star.to_vec());
7093            let eval_at = |rho_vec: &[f64]| -> (f64, ndarray::Array1<f64>, bool) {
7094                let diagnostics =
7095                    crate::custom_family::evaluate_labeled_outer_criterion_for_diagnostics(
7096                        &fam,
7097                        &parts.blocks,
7098                        &probe_options,
7099                        &ndarray::Array1::from(rho_vec.to_vec()),
7100                        gam_problem::EvalMode::ValueAndGradient,
7101                    )
7102                    .expect("labeled outer evaluation at the checkpoint");
7103                (
7104                    diagnostics.objective,
7105                    diagnostics.gradient,
7106                    diagnostics.inner_converged,
7107                )
7108            };
7109            let (v0, g0, conv0) = eval_at(&rho_star);
7110            eprintln!(
7111                "#2349 labeled-evaluator at rho*: V={v0:.9e} (refusal 2.687403e2, \
7112                 fixed-lambda LAML 2.561663540e2) inner_converged={conv0} |analytic g|={:.6e}",
7113                g0.iter().map(|g| g * g).sum::<f64>().sqrt()
7114            );
7115            let h = 1.0e-3;
7116            for s in 0..6 {
7117                let mut plus = rho_star;
7118                plus[s] += h;
7119                let mut minus = rho_star;
7120                minus[s] -= h;
7121                let (vp, _, _) = eval_at(&plus);
7122                let (vm, _, _) = eval_at(&minus);
7123                let fd = (vp - vm) / (2.0 * h);
7124                eprintln!(
7125                    "#2349 labeled grad[{s}]: analytic={:+.6e} fd={fd:+.6e} diff={:+.3e}",
7126                    g0[s],
7127                    g0[s] - fd
7128                );
7129            }
7130        }
7131    }
7132
7133    /// A deterministic two-covariate three-class dataset whose labels are DRAWN
7134    /// from a smooth softmax truth.
7135    ///
7136    /// Drawing rather than taking the argmax is load-bearing: an argmax label is
7137    /// a deterministic function of `x`, so the classes are exactly separated by
7138    /// their own decision boundaries and every fit on them is legitimately on
7139    /// the separation lane. Here every class keeps appreciable probability
7140    /// everywhere, so **no direction separates** and the unbiased criterion has
7141    /// an interior optimum. Same generator as
7142    /// `tests/multinomial_parametric_penalty_2612.rs`.
7143    fn softmax_drawn_two_covariate(
7144        dir: &std::path::Path,
7145        tag: &str,
7146        n: usize,
7147    ) -> gam_data::EncodedDataset {
7148        const CLASS_NAMES: [&str; 3] = ["a", "b", "c"];
7149        let mut lcg: u64 = 0x2612_2612_2612_2612;
7150        let mut next_unit = || -> f64 {
7151            lcg = lcg
7152                .wrapping_mul(6_364_136_223_846_793_005)
7153                .wrapping_add(1_442_695_040_888_963_407);
7154            ((lcg >> 11) as f64) / ((1u64 << 53) as f64)
7155        };
7156        let mut csv = String::from("x1,x2,y\n");
7157        for index in 0..n {
7158            let x1 = -2.0 + 4.0 * (((index as f64) * 0.618_033_988_749_894_8) % 1.0);
7159            let x2 = -2.0 + 4.0 * (((index as f64) * 0.414_213_562_373_095_1) % 1.0);
7160            let scores = [
7161                0.6 * x1 - 0.3 * x2,
7162                -0.4 * x1 + 0.5 * x2,
7163                0.2 * (x1 * x1 - x2 * x2) * 0.25,
7164            ];
7165            let shift = scores.iter().copied().fold(f64::NEG_INFINITY, f64::max);
7166            let weights: Vec<f64> = scores.iter().map(|s| (s - shift).exp()).collect();
7167            let total: f64 = weights.iter().sum();
7168            let mut draw = next_unit() * total;
7169            let mut label = CLASS_NAMES.len() - 1;
7170            for (class, weight) in weights.iter().enumerate() {
7171                if draw < *weight {
7172                    label = class;
7173                    break;
7174                }
7175                draw -= weight;
7176            }
7177            writeln!(csv, "{x1},{x2},{}", CLASS_NAMES[label]).unwrap();
7178        }
7179        let path = dir.join(format!("softmax_drawn_{tag}.csv"));
7180        fs::write(&path, csv).expect("write softmax-drawn csv");
7181        let cols: Vec<String> = ["x1", "x2", "y"].iter().map(|s| s.to_string()).collect();
7182        load_dataset_projected(&path, &cols).expect("load softmax-drawn dataset")
7183    }
7184
7185    /// #2612 diagnostic (zz_measure): **which matrix decides the arming, and
7186    /// what does each one say?**
7187    ///
7188    /// The conditional Firth/Jeffreys engagement fires when the reduced
7189    /// conditioning gate reports under-identification at the certified mode. The
7190    /// gate's absolute arm fires at `λ_min < 1` — one observation-equivalent of
7191    /// curvature — and until #2612 this call site handed it the bare Fisher
7192    /// information `H`. A penalized spline basis has high-frequency directions
7193    /// the data barely resolve BY CONSTRUCTION, so that reading calls an ordinary
7194    /// smooth "separated"; reading the penalized curvature `H + S_λ` asks the
7195    /// question of the objective that was actually optimized.
7196    ///
7197    /// This prints both spectra at the same certified mode, on the same span, on
7198    /// data drawn from a smooth softmax truth (nothing separates) and — when the
7199    /// checked-in CSV is present — on the penguins witness, which genuinely is
7200    /// quasi-separated. The two rows are the discriminator: the fix is only right
7201    /// if it disarms the first and leaves the second armed.
7202    ///
7203    /// Prints only; never asserts a bound.
7204    #[test]
7205    fn zz_measure_2612_arming_certificate_with_and_without_the_penalty() {
7206        let td = tempdir().expect("tempdir");
7207        let synthetic = softmax_drawn_two_covariate(td.path(), "arm2612", 600);
7208        let config = FitConfig::default();
7209        let mut cases: Vec<(&str, gam_data::EncodedDataset, &str)> = vec![(
7210            "synthetic softmax-drawn (nothing separates)",
7211            synthetic,
7212            "y ~ s(x1, k=6) + s(x2, k=6)",
7213        )];
7214        if let Some(train) = penguins_stride3_train(&td) {
7215            cases.push((
7216                "penguins stride-3 (the quasi-separated witness)",
7217                train,
7218                "species ~ s(bill_length_mm, k=10) + s(bill_depth_mm, k=10) \
7219                 + s(flipper_length_mm, k=10) + s(body_mass_g, k=10)",
7220            ));
7221        } else {
7222            eprintln!("#2612 penguins arm SKIPPED: dataset unavailable");
7223        }
7224
7225        for (label, data, formula) in &cases {
7226            let request = MultinomialFitRequest {
7227                init_lambda: 1.0,
7228                max_iter: 100,
7229                tol: 1e-8,
7230                ..MultinomialFitRequest::new(data, formula, &config)
7231            };
7232            let parts =
7233                penalized_multinomial_formula_parts(&request).expect("production formula parts");
7234            let started = std::time::Instant::now();
7235            let probe = crate::custom_family::fit_custom_family_with_rho_prior(
7236                &parts.family,
7237                &parts.blocks,
7238                &parts.options,
7239                gam_problem::RhoPrior::Flat,
7240            );
7241            let seconds = started.elapsed().as_secs_f64();
7242            let probe = match probe {
7243                Ok(fit) => fit,
7244                Err(err) => {
7245                    eprintln!(
7246                        "#2612 [{label}] unbiased probe FAILED after {seconds:.1}s: {}",
7247                        format!("{err}").chars().take(400).collect::<String>()
7248                    );
7249                    continue;
7250                }
7251            };
7252            let span = probe
7253                .geometry
7254                .as_ref()
7255                .expect("probe geometry")
7256                .coefficient_gauge
7257                .t_full
7258                .clone();
7259            let specs = parts
7260                .family
7261                .equivariant_class_penalty_specs()
7262                .expect("coupled joint penalty specs");
7263            let n_components = parts.penalties_arc.len();
7264            let selected = probe.artifacts.joint_log_lambdas.as_ref();
7265            // The historical reading: the likelihood alone, `S_λ` omitted.
7266            let likelihood_only = multinomial_formula_penalized_separation_evidence(
7267                &parts.family,
7268                &parts.blocks,
7269                &probe.block_states,
7270                span.view(),
7271                &[],
7272                0,
7273                None,
7274            );
7275            // The curvature the mode actually sits in.
7276            let penalized = multinomial_formula_penalized_separation_evidence(
7277                &parts.family,
7278                &parts.blocks,
7279                &probe.block_states,
7280                span.view(),
7281                &specs,
7282                n_components,
7283                selected,
7284            );
7285            let lambda_range = selected.map(|jll| {
7286                let lo = jll.iter().copied().fold(f64::INFINITY, f64::min);
7287                let hi = jll.iter().copied().fold(f64::NEG_INFINITY, f64::max);
7288                (lo.exp(), hi.exp(), jll.len())
7289            });
7290            eprintln!(
7291                "#2612 [{label}] probe converged in {seconds:.1}s; selected lambda \
7292                 (min, max, count) = {lambda_range:?}\n    H alone      -> {:?}\n    H + S_lambda -> {:?}",
7293                likelihood_only
7294                    .as_ref()
7295                    .map(|e| e.as_ref().map(MultinomialSeparationCertificate::as_str)),
7296                penalized
7297                    .as_ref()
7298                    .map(|e| e.as_ref().map(MultinomialSeparationCertificate::as_str)),
7299            );
7300        }
7301    }
7302}