Skip to main content

gam_spec/
lib.rs

1use ndarray::{Array1, ArrayView1};
2use serde::{Deserialize, Serialize};
3
4/// Hyperprior placed on a coefficient group's precision / log-precision.
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub enum CoefficientGroupPrior {
7    Flat,
8    NormalLogPrecision {
9        mean: f64,
10        sd: f64,
11    },
12    GammaPrecision {
13        shape: f64,
14        rate: f64,
15    },
16    /// Penalized-complexity prior calibrated by `P(exp(-rho/2) > upper) =
17    /// tail_prob`; see [`RhoPrior::PenalizedComplexity`].
18    PenalizedComplexity {
19        upper: f64,
20        tail_prob: f64,
21    },
22}
23
24impl CoefficientGroupPrior {
25    pub fn to_rho_prior(&self) -> RhoPrior {
26        match *self {
27            Self::Flat => RhoPrior::Flat,
28            Self::NormalLogPrecision { mean, sd } => RhoPrior::Normal { mean, sd },
29            Self::GammaPrecision { shape, rate } => RhoPrior::GammaPrecision { shape, rate },
30            Self::PenalizedComplexity { upper, tail_prob } => {
31                RhoPrior::PenalizedComplexity { upper, tail_prob }
32            }
33        }
34    }
35
36    pub fn validate(&self, context: &str) -> Result<(), String> {
37        match *self {
38            Self::Flat => Ok(()),
39            Self::NormalLogPrecision { mean, sd } => {
40                if !mean.is_finite() {
41                    return Err(format!(
42                        "{context} Normal log-precision prior requires finite mean, got {mean}"
43                    ));
44                }
45                if !sd.is_finite() || sd <= 0.0 {
46                    return Err(format!(
47                        "{context} Normal log-precision prior requires sd > 0, got {sd}"
48                    ));
49                }
50                Ok(())
51            }
52            Self::GammaPrecision { shape, rate } => {
53                if !shape.is_finite() || shape <= 0.0 {
54                    return Err(format!(
55                        "{context} Gamma precision prior requires shape > 0, got {shape}"
56                    ));
57                }
58                if !rate.is_finite() || rate < 0.0 {
59                    return Err(format!(
60                        "{context} Gamma precision prior requires rate >= 0, got {rate}"
61                    ));
62                }
63                Ok(())
64            }
65            Self::PenalizedComplexity { upper, tail_prob } => {
66                if !upper.is_finite() || upper <= 0.0 {
67                    return Err(format!(
68                        "{context} penalized-complexity prior requires upper > 0, got {upper}"
69                    ));
70                }
71                if !tail_prob.is_finite() || tail_prob <= 0.0 || tail_prob >= 1.0 {
72                    return Err(format!(
73                        "{context} penalized-complexity prior requires tail probability in (0, 1), got {tail_prob}"
74                    ));
75                }
76                Ok(())
77            }
78        }
79    }
80}
81
82/// Shared default for monotone wiggle/deviation blocks. Formula DSL defaults,
83/// workflow configs, and runtime deviation blocks should all derive from this
84/// type so reproducible presets do not drift across layers.
85#[derive(Clone, Debug, Serialize, Deserialize)]
86pub struct WigglePenaltyConfig {
87    pub degree: usize,
88    pub num_internal_knots: usize,
89    pub penalty_orders: Vec<usize>,
90    pub double_penalty: bool,
91    pub monotonicity_eps: f64,
92}
93
94impl WigglePenaltyConfig {
95    pub fn cubic_triple_operator_default() -> Self {
96        Self {
97            degree: 3,
98            num_internal_knots: 8,
99            penalty_orders: vec![1, 2, 3],
100            double_penalty: true,
101            monotonicity_eps: 1e-4,
102        }
103    }
104}
105
106/// Shared engine-level link selector for generalized models. This is the
107/// "wide" link descriptor: CLI parsing, formula DSL, and the projection from
108/// `InverseLink::link_function()` all live in this enum, so it carries every
109/// link kind the engine knows about — including the state-bearing
110/// `Sas` / `BetaLogistic` cases.
111///
112/// `LinkFunction` is *not* the right type for the state-less `InverseLink::Standard`
113/// cell. Use [`StandardLink`] there: the type system then refuses to construct
114/// a state-less `Standard(Sas)` / `Standard(BetaLogistic)` placeholder.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub enum LinkFunction {
117    Logit,
118    Probit,
119    CLogLog,
120    LogLog,
121    Cauchit,
122    Sas,
123    BetaLogistic,
124    Identity,
125    Log,
126}
127
128impl LinkFunction {
129    #[inline]
130    pub const fn name(self) -> &'static str {
131        match self {
132            Self::Logit => "logit",
133            Self::Probit => "probit",
134            Self::CLogLog => "cloglog",
135            Self::LogLog => "loglog",
136            Self::Cauchit => "cauchit",
137            Self::Sas => "sas",
138            Self::BetaLogistic => "beta-logistic",
139            Self::Identity => "identity",
140            Self::Log => "log",
141        }
142    }
143}
144
145/// Legal-only link descriptor for the state-less `InverseLink::Standard` cell.
146///
147/// `Sas` / `BetaLogistic` are state-bearing and live in their own
148/// `InverseLink::Sas(_)` / `InverseLink::BetaLogistic(_)` variants. The type
149/// system enforces that fact by omitting them here, so the historical
150/// "state-less placeholder" pattern (`InverseLink::Standard(LinkFunction::Sas)`)
151/// no longer compiles.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153pub enum StandardLink {
154    Logit,
155    Probit,
156    CLogLog,
157    LogLog,
158    Cauchit,
159    Identity,
160    Log,
161}
162
163impl StandardLink {
164    #[inline]
165    pub const fn name(self) -> &'static str {
166        self.as_link_function().name()
167    }
168
169    #[inline]
170    pub const fn as_link_function(self) -> LinkFunction {
171        match self {
172            Self::Logit => LinkFunction::Logit,
173            Self::Probit => LinkFunction::Probit,
174            Self::CLogLog => LinkFunction::CLogLog,
175            Self::LogLog => LinkFunction::LogLog,
176            Self::Cauchit => LinkFunction::Cauchit,
177            Self::Identity => LinkFunction::Identity,
178            Self::Log => LinkFunction::Log,
179        }
180    }
181}
182
183impl From<StandardLink> for LinkFunction {
184    #[inline]
185    fn from(link: StandardLink) -> Self {
186        link.as_link_function()
187    }
188}
189
190/// Error returned when narrowing a wide [`LinkFunction`] into a [`StandardLink`].
191/// `Sas` and `BetaLogistic` are state-bearing and have no legal `Standard(_)`
192/// representation; they must be routed through `InverseLink::Sas` /
193/// `InverseLink::BetaLogistic`.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub struct StateBearingLinkInStandardSlot(pub LinkFunction);
196
197impl std::fmt::Display for StateBearingLinkInStandardSlot {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        write!(
200            f,
201            "state-bearing link `{}` cannot be carried by `InverseLink::Standard`; \
202             route through `InverseLink::Sas` / `InverseLink::BetaLogistic`",
203            self.0.name()
204        )
205    }
206}
207
208impl std::error::Error for StateBearingLinkInStandardSlot {}
209
210impl TryFrom<LinkFunction> for StandardLink {
211    type Error = StateBearingLinkInStandardSlot;
212
213    #[inline]
214    fn try_from(link: LinkFunction) -> Result<Self, Self::Error> {
215        match link {
216            LinkFunction::Logit => Ok(Self::Logit),
217            LinkFunction::Probit => Ok(Self::Probit),
218            LinkFunction::CLogLog => Ok(Self::CLogLog),
219            LinkFunction::LogLog => Ok(Self::LogLog),
220            LinkFunction::Cauchit => Ok(Self::Cauchit),
221            LinkFunction::Identity => Ok(Self::Identity),
222            LinkFunction::Log => Ok(Self::Log),
223            LinkFunction::Sas | LinkFunction::BetaLogistic => {
224                Err(StateBearingLinkInStandardSlot(link))
225            }
226        }
227    }
228}
229
230/// Supported inverse-link components for convex blended inverse links.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232pub enum LinkComponent {
233    Probit,
234    Logit,
235    CLogLog,
236    LogLog,
237    Cauchit,
238}
239
240impl LinkComponent {
241    #[inline]
242    pub const fn name(self) -> &'static str {
243        match self {
244            Self::Probit => "probit",
245            Self::Logit => "logit",
246            Self::CLogLog => "cloglog",
247            Self::LogLog => "loglog",
248            Self::Cauchit => "cauchit",
249        }
250    }
251}
252
253/// User-facing configuration for a blended inverse link.
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255pub struct MixtureLinkSpec {
256    pub components: Vec<LinkComponent>,
257    /// Free logits for components [0..K-2]. The final component logit is fixed at 0.
258    pub initial_rho: Array1<f64>,
259}
260
261/// Runtime blended-link state with precomputed softmax weights.
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263pub struct MixtureLinkState {
264    pub components: Vec<LinkComponent>,
265    /// Free logits for components [0..K-2]. The final component logit is fixed at 0.
266    pub rho: Array1<f64>,
267    /// Softmax-normalized component weights (length K).
268    pub pi: Array1<f64>,
269}
270
271/// User-facing configuration for the continuous sinh-arcsinh inverse link.
272#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
273pub struct SasLinkSpec {
274    pub initial_epsilon: f64,
275    pub initial_log_delta: f64,
276}
277
278/// Runtime state shared by the two-parameter `Sas` and `BetaLogistic` links:
279/// an `epsilon` skew/asymmetry term plus a raw log-scale parameter (`log_delta`)
280/// and its derived positive companion (`delta`). The `delta` field's meaning is
281/// link-specific — see its doc — so derivative kernels must consume `log_delta`,
282/// never `delta`.
283#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
284pub struct SasLinkState {
285    pub epsilon: f64,
286    /// Raw optimization parameter. For `Sas` this is the pre-bound log-tail; for
287    /// `BetaLogistic` it is the unconstrained log geometric-mean beta shape (the
288    /// `log_shape_center` the beta-logistic kernels expect).
289    pub log_delta: f64,
290    /// Derived positive companion of `log_delta`. Its meaning depends on the link:
291    /// - `Sas`: effective tail parameter `delta = exp(B * tanh(log_delta / B))`,
292    ///   `B = SAS_LOG_DELTA_BOUND`.
293    /// - `BetaLogistic`: geometric-mean beta shape `exp(log_delta) = sqrt(a*b)`.
294    ///
295    /// The beta-logistic derivative kernels take `log_delta` (the log center), so
296    /// passing this exponentiated `delta` to them would be off by an `exp`.
297    pub delta: f64,
298}
299
300/// Fixed latent Gaussian scale for the exact marginal cloglog family.
301#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
302pub struct LatentCLogLogState {
303    pub latent_sd: f64,
304}
305
306impl LatentCLogLogState {
307    #[inline]
308    pub fn new(latent_sd: f64) -> Result<Self, String> {
309        if !latent_sd.is_finite() || latent_sd < 0.0 {
310            return Err(format!(
311                "latent cloglog standard deviation must be finite and >= 0, got {latent_sd}"
312            ));
313        }
314        Ok(Self { latent_sd })
315    }
316}
317
318/// Parameterized inverse-link selector used where mu/derivatives are evaluated.
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320pub enum InverseLink {
321    Standard(StandardLink),
322    LatentCLogLog(LatentCLogLogState),
323    Sas(SasLinkState),
324    BetaLogistic(SasLinkState),
325    Mixture(MixtureLinkState),
326}
327
328impl InverseLink {
329    #[inline]
330    pub const fn link_function(&self) -> LinkFunction {
331        match self {
332            Self::Standard(link) => link.as_link_function(),
333            Self::LatentCLogLog(_) => LinkFunction::CLogLog,
334            Self::Sas(_) => LinkFunction::Sas,
335            Self::BetaLogistic(_) => LinkFunction::BetaLogistic,
336            Self::Mixture(_) => LinkFunction::Logit,
337        }
338    }
339
340    #[inline]
341    pub const fn mixture_state(&self) -> Option<&MixtureLinkState> {
342        match self {
343            Self::Mixture(state) => Some(state),
344            _ => None,
345        }
346    }
347
348    #[inline]
349    pub const fn sas_state(&self) -> Option<&SasLinkState> {
350        match self {
351            Self::Sas(state) | Self::BetaLogistic(state) => Some(state),
352            _ => None,
353        }
354    }
355
356    #[inline]
357    pub const fn latent_cloglog_state(&self) -> Option<&LatentCLogLogState> {
358        match self {
359            Self::LatentCLogLog(state) => Some(state),
360            _ => None,
361        }
362    }
363
364    /// Whether this inverse link exposes the Fisher-weight jet consumed by
365    /// higher-order Firth/Jeffreys corrections.
366    ///
367    /// The numerical jet evaluation lives in `gam-solve`; the capability is a
368    /// property of the link vocabulary and therefore belongs here with the
369    /// variants it classifies.
370    #[inline]
371    pub const fn has_fisher_weight_jet(&self) -> bool {
372        matches!(
373            self,
374            Self::Standard(
375                StandardLink::Logit
376                    | StandardLink::Probit
377                    | StandardLink::CLogLog
378                    | StandardLink::LogLog
379                    | StandardLink::Cauchit,
380            ) | Self::LatentCLogLog(_)
381                | Self::Sas(_)
382                | Self::BetaLogistic(_)
383                | Self::Mixture(_)
384        )
385    }
386}
387
388/// Fixed prior family for smoothing parameters in joint HMC refinement.
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
390pub enum RhoPrior {
391    Flat,
392    Normal {
393        mean: f64,
394        sd: f64,
395    },
396    /// Gamma(shape, rate) conjugate hyperprior on the precision lambda = exp(rho).
397    ///
398    /// The deterministic REML/LAML objective uses the MAP-in-lambda convention
399    /// and is minimized, so this contributes `rate * exp(rho) - (shape - 1) * rho`
400    /// up to an additive constant. Samplers over rho include the +rho Jacobian
401    /// from lambda = exp(rho), so their log-density contribution is
402    /// `shape * rho - rate * exp(rho)`. For a block with effective dimension n_p
403    /// and centered quadratic
404    /// `(beta - mu)'S_p(beta - mu)`, the conditional posterior is
405    /// `Gamma(shape + n_p/2, rate + quadratic/2)` and the closed-form MAP
406    /// precision is `(shape + n_p/2 - 1) / (rate + quadratic/2)`.
407    /// `Gamma(1, 0)` is the explicit flat/default case and reproduces the
408    /// current MacKay/Tipping fixed point.
409    GammaPrecision {
410        shape: f64,
411        rate: f64,
412    },
413    /// Penalized-complexity (PC) prior on the smoothing parameter
414    /// (Simpson, Rue, Riebler, Martins, Sørbye, *Statistical Science* 2017).
415    ///
416    /// A PC prior fixes a *base* model (here the infinitely-smooth limit, where
417    /// the penalized component collapses to its null space) and puts an
418    /// exponential prior on the distance away from it. For a Gaussian smooth
419    /// with precision `λ = exp(ρ)` the relevant distance is the marginal
420    /// standard-deviation scale `d = λ^{-1/2} = exp(-ρ/2)`, and a constant-rate
421    /// penalization `p(d) = θ exp(-θ d)` induces the closed-form log-prior
422    ///
423    /// ```text
424    /// log p(ρ) = ln(θ/2) − ρ/2 − θ exp(−ρ/2).
425    /// ```
426    ///
427    /// The rate `θ` is calibrated by the single interpretable tail statement
428    /// `P(d > upper) = tail_prob`, i.e. `θ = −ln(tail_prob) / upper`. The prior
429    /// is reparameterization-invariant and shrinks toward the simpler model
430    /// (an exponential wall against under-smoothing, only a gentle linear pull
431    /// toward over-smoothing), which is exactly the Occam behaviour wanted for
432    /// high-variance flexible components. The REML/LAML objective is minimized,
433    /// so this contributes `ρ/2 + θ exp(−ρ/2)` (up to an additive constant) to
434    /// the cost, with gradient `1/2 − (θ/2) exp(−ρ/2)` and (always positive)
435    /// curvature `(θ/4) exp(−ρ/2)`.
436    PenalizedComplexity {
437        /// Upper bound `U` on the distance scale `d = exp(-ρ/2)` (the marginal
438        /// SD scale of the penalized component) in the tail statement
439        /// `P(d > U) = tail_prob`. Must be finite and strictly positive.
440        upper: f64,
441        /// Tail probability `α` in `P(d > U) = α`. Must satisfy `0 < α < 1`.
442        tail_prob: f64,
443    },
444    /// Coordinate-specific priors for models whose smoothing parameters do
445    /// not share one prior family, such as nested coefficient groups.
446    Independent(Vec<RhoPrior>),
447}
448
449impl RhoPrior {
450    /// Does this prior contribute **nothing** to `∂V/∂ρ_k` in the λ→∞
451    /// (`ρ_k → +∞`) tail?
452    ///
453    /// Every rail-reasoning path in the outer certificate — the analytic λ=∞
454    /// face form and the measured-tail probe alike — decides by the law
455    ///
456    /// ```text
457    /// ĉ = −e^{ρ} ∂V/∂ρ   is CONSTANT along the tail,
458    /// ```
459    ///
460    /// which is a statement about a REML/LAML criterion, whose λ=∞ face gives
461    /// `∂V/∂ρ = O(e^{−ρ})`. It holds only when the criterion really is that
462    /// one. Any ρ-prior whose own gradient survives into the tail adds a term
463    /// the law does not model, `ĉ` diverges, and **there is no λ=∞ face to
464    /// certify at any box width** (#2450: under `Normal { 0, 3 }` the measured
465    /// `∂V/∂ρ → ρ/9` and `ĉ → −ρe^{ρ}/9`).
466    ///
467    /// Carrying the answer here rather than re-deriving it at each consumer is
468    /// the point: the previous consumer-side test was `matches!(prior, Flat)`,
469    /// which silently misclassifies the two other spellings of a flat
470    /// coordinate.
471    ///
472    /// True exactly for the families whose ρ-gradient is identically zero on
473    /// the identified upper tail:
474    ///
475    /// * `Flat` — the REML runtime evaluates unset coordinates through the
476    ///   SELF-GATED, one-sided firth-default barrier, which is byte-identically
477    ///   flat above `ρ = −2 ln(upper)`. The wall it does carry is on the
478    ///   *other* side, against the `λ → 0` under-smoothing degeneracy.
479    /// * `GammaPrecision { shape: 1, rate: 0 }` — exactly flat under the
480    ///   MAP-in-λ convention (cost, gradient and Hessian all vanish); the same
481    ///   "unset" coordinate as `Flat`, spelled differently.
482    ///
483    /// False for every other family, each for a nameable reason: `Normal`
484    /// leaves `(ρ − mean)/sd² → ∞`; `PenalizedComplexity` leaves its persistent
485    /// `+1/2` Occam pull; a `GammaPrecision` with `rate > 0` leaves
486    /// `rate·e^{ρ}`, which diverges faster than the law's own scale.
487    ///
488    /// `coordinate` indexes the ρ vector, so an `Independent` prior answers per
489    /// coordinate — a face can be certifiable on one coordinate and not on
490    /// another. A nested `Independent` is structurally invalid and answers
491    /// `false` rather than pretending to know.
492    pub fn upper_tail_gradient_vanishes(&self, coordinate: usize) -> bool {
493        fn scalar_vanishes(prior: &RhoPrior) -> bool {
494            match prior {
495                RhoPrior::Flat => true,
496                RhoPrior::GammaPrecision { shape, rate } => *shape == 1.0 && *rate == 0.0,
497                RhoPrior::Normal { .. }
498                | RhoPrior::PenalizedComplexity { .. }
499                | RhoPrior::Independent(_) => false,
500            }
501        }
502        match self {
503            RhoPrior::Independent(priors) => priors.get(coordinate).is_some_and(scalar_vanishes),
504            scalar => scalar_vanishes(scalar),
505        }
506    }
507
508    /// Does the upper-tail law hold on **every** coordinate of a `ρ` of length
509    /// `rho_dim`? An `Independent` prior shorter than `rho_dim` is malformed
510    /// and answers `false`.
511    pub fn upper_tail_gradient_vanishes_everywhere(&self, rho_dim: usize) -> bool {
512        (0..rho_dim).all(|k| self.upper_tail_gradient_vanishes(k))
513    }
514
515    /// Did the caller leave this prior UNSET, or did they configure it?
516    ///
517    /// `RhoPrior::default()` is `Flat`, so "unset" and "explicitly asked for a
518    /// flat coordinate" are the same value and deliberately indistinguishable —
519    /// both mean *the criterion is pure REML/LAML here*, which is what any
520    /// consumer of this predicate actually wants to know. What is NOT the same
521    /// is a configured `Normal` / `PenalizedComplexity` / non-trivial
522    /// `GammaPrecision`: that value is a modelling choice someone wrote down,
523    /// and a subsystem that rewrites it is discarding an instruction (#2463).
524    ///
525    /// Unset has more than one spelling. `GammaPrecision { shape: 1, rate: 0 }`
526    /// is "the explicit flat/default case" by this enum's own documentation —
527    /// its cost, gradient and Hessian all vanish under the MAP-in-λ convention —
528    /// so a `matches!(prior, Flat)` test silently misclassifies it as
529    /// configured. Carrying the answer here rather than re-deriving it at each
530    /// consumer is the point (#2427): the same question asked in three places
531    /// must not get three answers.
532    ///
533    /// An `Independent` prior is unset only when EVERY coordinate is, since a
534    /// single configured coordinate is still an instruction. An empty
535    /// `Independent` carries no instruction and is therefore unset.
536    pub fn is_unset(&self) -> bool {
537        match self {
538            RhoPrior::Flat => true,
539            RhoPrior::GammaPrecision { shape, rate } => *shape == 1.0 && *rate == 0.0,
540            RhoPrior::Normal { .. } | RhoPrior::PenalizedComplexity { .. } => false,
541            RhoPrior::Independent(priors) => priors.iter().all(RhoPrior::is_unset),
542        }
543    }
544}
545
546/// `Flat`, because the *deterministic* criterion is REML/LAML by contract.
547///
548/// SPEC: "REML (or LAML) always used for fitting, never GCV" and "posterior
549/// mean must always be the default (never MAP)". A `Default` that installs a
550/// proper prior on `ρ` makes the shipped deterministic criterion
551/// `REML + Σ_k ρ_k²/(2 sd²)` — MAP in `ρ` — which is a different estimator,
552/// not a stabilised version of the same one. `gam-custom-family` has always
553/// passed `Flat` explicitly at its entry point for exactly this reason; the
554/// `gam-models` / CLI / Python path reached the same solver through
555/// `FitOptions::default()` and inherited a prior the enum's own doc comment
556/// scopes to *joint HMC refinement* (#2450).
557///
558/// **The sampler does not lose its prior by this.** A flat prior on `ρ` gives
559/// an IMPROPER joint posterior — as `ρ → ∞` the REML criterion tends to the
560/// finite λ=∞ face value, so `exp(−V)` does not decay and `∫dρ` diverges — and
561/// that is precisely why joint HMC needs a proper one. The runtime already
562/// separates the two: `resolve_effective_rho_prior` fills unset (`Flat`)
563/// coordinates with the weakly-informative penalized-complexity default for
564/// consumers that need a *distribution* (serialization, joint-HMC refinement),
565/// while the REML/LAML objective evaluates those same coordinates through the
566/// self-gated one-sided barrier, which is byte-identically flat on the
567/// identified side. One default cannot serve both questions; `Flat` is the
568/// answer to "what criterion am I minimizing", and the sampler's answer is
569/// derived from it rather than shared with it.
570impl Default for RhoPrior {
571    fn default() -> Self {
572        Self::Flat
573    }
574}
575
576// ---------------------------------------------------------------------------
577// Unified likelihood specification
578// ---------------------------------------------------------------------------
579//
580// `LikelihoodSpec { response: ResponseFamily, link: InverseLink }` is the
581// canonical likelihood selector. `ResponseFamily` is a pure response-
582// distribution selector that carries the per-family scalars
583// (`Tweedie { p }`, `NegativeBinomial { theta }`, `Beta { phi }`); `InverseLink`
584// is the parameterized inverse-link selector. Splitting (response, link)
585// removes the drift bug that the former flat likelihood enum allowed
586// when its variant disagreed with a separately-stored `InverseLink`.
587
588/// Pure response distribution selector — no link information.
589#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
590pub enum ResponseFamily {
591    Gaussian,
592    Binomial,
593    Poisson,
594    Tweedie {
595        p: f64,
596    },
597    NegativeBinomial {
598        theta: f64,
599        /// `true` when `theta` was supplied by the user as a held-fixed value
600        /// (`--negative-binomial-theta`, issue #983): the fit must use exactly
601        /// this overdispersion — `Var(y) = μ + μ²/θ`, IRLS weight
602        /// `W = μθ/(θ+μ)`, coefficients/covariance/SEs all reflect it — and
603        /// the inner solver must never overwrite it. `false` means `theta` is
604        /// the running seed/estimate refined from the data each inner solve
605        /// (the #802 default). Carried on the family variant — the canonical
606        /// `theta` store — so the estimated-vs-fixed contract can never desync
607        /// from the value itself; `default_scale_metadata` derives the
608        /// matching scale variant.
609        theta_fixed: bool,
610    },
611    Beta {
612        phi: f64,
613    },
614    Gamma,
615    RoystonParmar,
616}
617
618impl ResponseFamily {
619    #[inline]
620    pub const fn name(&self) -> &'static str {
621        match self {
622            Self::Gaussian => "gaussian",
623            Self::Binomial => "binomial",
624            Self::Poisson => "poisson",
625            Self::Tweedie { .. } => "tweedie",
626            Self::NegativeBinomial { .. } => "negative-binomial",
627            Self::Beta { .. } => "beta",
628            Self::Gamma => "gamma",
629            Self::RoystonParmar => "royston-parmar",
630        }
631    }
632
633    /// Closed-interval bounds for the mean (response-scale) of this family.
634    ///
635    /// Used by predict-side CI clamps that need to keep transformed bounds
636    /// within the support of the response. Beta uses strict-open `(1e-10, 1 − 1e-10)`
637    /// to avoid logit singularities; Binomial / Royston-Parmar use the closed
638    /// `[0, 1]` since they are evaluated post-transformation. Unbounded
639    /// (continuous-real or non-negative-real) families return `None` — the
640    /// caller should not clamp.
641    #[inline]
642    pub fn mean_clamp_bounds(&self) -> Option<(f64, f64)> {
643        match self {
644            Self::Binomial | Self::RoystonParmar => Some((0.0, 1.0)),
645            Self::Beta { .. } => Some((1e-10, 1.0 - 1e-10)),
646            Self::Gaussian
647            | Self::Poisson
648            | Self::Tweedie { .. }
649            | Self::NegativeBinomial { .. }
650            | Self::Gamma => None,
651        }
652    }
653
654    /// Closed numeric bounds of the **response support** — the closure of the
655    /// set of values a single observation `Y` can take — used to clamp the
656    /// *observation (prediction) interval* so a predictive band never reports
657    /// values the response can never attain.
658    ///
659    /// This is deliberately distinct from [`Self::mean_clamp_bounds`], which
660    /// governs the *mean* (confidence) interval. `mean_clamp_bounds` returns
661    /// `None` for the non-negative-real families (Poisson / Tweedie /
662    /// NegativeBinomial / Gamma) because their default mean interval is built
663    /// by transforming the η endpoints through a positive inverse link, which
664    /// cannot escape the support. The observation interval, by contrast, is the
665    /// symmetric response-scale band `μ ± z·σ_pred`; for a small fitted mean its
666    /// lower endpoint crosses below the support floor (e.g. a Poisson count band
667    /// going negative), so it must be floored at the response support here.
668    ///
669    /// The lower edge is the infimum of the support (`0` for every non-negative
670    /// family, including the open-at-zero Gamma, whose predictive lower bound is
671    /// reported at the boundary `0`). The upper edge is `+∞` where the response
672    /// is unbounded above, which leaves the upper band untouched, or `1` for the
673    /// `[0, 1]`-valued families. `None` means the response is supported on the
674    /// whole real line (Gaussian) or has its support enforced downstream
675    /// (Royston–Parmar), and the predictive band is passed through unclamped.
676    ///
677    /// The match arms mirror [`Self::response_support_contains`]: a new family
678    /// must update both together so the support a value is validated against and
679    /// the support a predictive band is clamped to stay consistent.
680    #[inline]
681    pub fn response_support_bounds(&self) -> Option<(f64, f64)> {
682        match self {
683            Self::Gamma | Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
684                Some((0.0, f64::INFINITY))
685            }
686            Self::Beta { .. } | Self::Binomial => Some((0.0, 1.0)),
687            Self::Gaussian | Self::RoystonParmar => None,
688        }
689    }
690
691    /// Per-family textual description of the response-support requirement.
692    /// `None` means the family is supported on the entire real line at the
693    /// validation layer (Gaussian) or has its support enforced by a downstream
694    /// pathway (RoystonParmar via the survival pipeline).
695    ///
696    /// `Binomial` accepts Bernoulli observations and grouped-binomial
697    /// proportions. With prior/trial weights folded into the row weight, the
698    /// per-unit log-likelihood is `ℓ(η) = y·η − log(1 + exp(η))`, which is
699    /// bounded exactly for `0 ≤ y ≤ 1`; outside that interval one tail is
700    /// unbounded and the binomial deviance leaves its domain. Strict `{0, 1}`
701    /// binarity remains an auto-inference policy, not the support of an
702    /// explicitly requested binomial model.
703    #[inline]
704    pub fn response_support_requirement(&self) -> Option<&'static str> {
705        match self {
706            Self::Gamma => Some("strictly positive response values (y > 0)"),
707            Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
708                Some("non-negative response values (y ≥ 0)")
709            }
710            Self::Beta { .. } => Some(
711                "response values strictly in the open interval (0, 1) \
712                 (a binary {0, 1} response is a Binomial GLM, not Beta; route it through the Binomial family instead)",
713            ),
714            Self::Binomial => Some("response values in the closed interval [0, 1]"),
715            Self::Gaussian | Self::RoystonParmar => None,
716        }
717    }
718
719    /// Predicate that returns `true` iff `yi` lies in this family's response
720    /// support. Only meaningful for families with a non-trivial domain
721    /// constraint at the validation layer; `validate_response_support` calls
722    /// this only after `response_support_requirement` returns `Some`, so the
723    /// "unconstrained" families (Gaussian / RoystonParmar) never hit this code
724    /// path.
725    ///
726    /// `Binomial` accepts Bernoulli outcomes and grouped-binomial proportions:
727    /// `y` must be finite and lie in the closed unit interval. The stricter
728    /// `{0, 1}` predicate is used only where the code is specifically asking
729    /// whether a numeric response is binary (auto-inference and all-boundary
730    /// degeneracy checks).
731    #[inline]
732    fn response_support_contains(&self, yi: f64) -> bool {
733        match self {
734            Self::Gamma => yi.is_finite() && yi > 0.0,
735            Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
736                yi.is_finite() && yi >= 0.0
737            }
738            Self::Beta { .. } => yi.is_finite() && yi > 0.0 && yi < 1.0,
739            Self::Binomial => yi.is_finite() && (0.0..=1.0).contains(&yi),
740            Self::Gaussian | Self::RoystonParmar => true,
741        }
742    }
743
744    /// Human-readable family label used in domain-violation error messages
745    /// (capitalised to match user-facing prose, distinct from `name()` which
746    /// returns the lowercase canonical identifier).
747    #[inline]
748    fn response_support_label(&self) -> &'static str {
749        match self {
750            Self::Gaussian => "Gaussian",
751            Self::Binomial => "Binomial",
752            Self::Poisson => "Poisson",
753            Self::Tweedie { .. } => "Tweedie",
754            Self::NegativeBinomial { .. } => "Negative-Binomial",
755            Self::Beta { .. } => "Beta",
756            Self::Gamma => "Gamma",
757            Self::RoystonParmar => "Royston-Parmar",
758        }
759    }
760
761    /// Validate that every element of `y` lies in this family's response
762    /// support. The check is the upfront, fit-blocking enforcement of the
763    /// family's distributional support — e.g. Gamma rejects `y ≤ 0` because
764    /// the log-likelihood contains `log(y)`, Poisson rejects `y < 0` because
765    /// the log-mass contains `log(y!)`.
766    ///
767    /// Returns `Ok(())` for families whose support is the entire real line at
768    /// this layer (Gaussian) or whose support is enforced by a downstream
769    /// pathway (RoystonParmar via the survival pipeline). `Binomial` is
770    /// enforced here: `0 ≤ y ≤ 1` keeps the Bernoulli / grouped-binomial
771    /// log-likelihood bounded.
772    ///
773    /// Up to `ResponseSupportViolation::MAX_REPORTED` offending row indices
774    /// are returned in the violation so the message stays bounded on large
775    /// datasets while still identifying offending rows.
776    pub fn validate_response_support(
777        &self,
778        y: ArrayView1<'_, f64>,
779    ) -> Result<(), ResponseSupportViolation> {
780        let requirement = match self.response_support_requirement() {
781            Some(r) => r,
782            None => return Ok(()),
783        };
784        let mut offending: Vec<(usize, f64)> = Vec::new();
785        let mut total_violations: usize = 0;
786        for (i, &yi) in y.iter().enumerate() {
787            if !self.response_support_contains(yi) {
788                total_violations += 1;
789                if offending.len() < ResponseSupportViolation::MAX_REPORTED {
790                    offending.push((i, yi));
791                }
792            }
793        }
794        if total_violations == 0 {
795            Ok(())
796        } else {
797            Err(ResponseSupportViolation {
798                family_label: self.response_support_label(),
799                requirement,
800                offending,
801                total_violations,
802            })
803        }
804    }
805
806    /// Detect a *degenerate* response: one whose value distribution makes the
807    /// family's REML log-likelihood non-finite even though every individual
808    /// `y_i` lies inside the family's distributional support.
809    ///
810    /// Symmetric counterpart to [`Self::validate_response_support`]: support
811    /// rejects out-of-domain *values* (e.g. a negative Poisson count); this
812    /// rejects *distributions* that send the saturated MLE to a boundary at
813    /// which the score diverges. Each family answers the question for itself
814    /// — adding a new family does not require touching workflow.rs.
815    ///
816    /// Concretely:
817    /// * `Binomial` — refuses an all-zero or all-one response: the saturated
818    ///   logit is ±∞ and the REML score is +∞ (issue #331).
819    /// * `Poisson` / `NegativeBinomial` — refuse an all-zero response: the
820    ///   count-rate optimum is at η = −∞, so no finite mode or posterior
821    ///   moments exist (#2255).
822    pub fn validate_response_degeneracy(
823        &self,
824        y: ArrayView1<'_, f64>,
825    ) -> Result<(), ResponseDegeneracy> {
826        match self {
827            Self::Binomial => {
828                if y.is_empty() {
829                    return Ok(());
830                }
831                let all_zeros = y.iter().all(|&yi| (yi - 0.0).abs() < BINOMIAL_BINARY_TOL);
832                let all_ones = y.iter().all(|&yi| (yi - 1.0).abs() < BINOMIAL_BINARY_TOL);
833                let kind = if all_zeros {
834                    ResponseDegeneracyKind::BinomialAllZeros
835                } else if all_ones {
836                    ResponseDegeneracyKind::BinomialAllOnes
837                } else {
838                    return Ok(());
839                };
840                Err(ResponseDegeneracy {
841                    family_label: self.response_support_label(),
842                    kind,
843                })
844            }
845            Self::Gaussian => {
846                // A Gaussian fit's marginal REML log-likelihood carries a
847                // `−n/2·log σ²` term; for an effectively-constant response the
848                // ML scale `σ → 0` drives it to `+∞`, so the outer objective
849                // rejects every seed with "reml_score must be finite, got inf"
850                // (#332). Reject pre-fit when the two-pass, mean-centred sample
851                // sd is at or below `GAUSSIAN_MIN_SAMPLE_SD`. Fewer than two
852                // observations carries no estimable scale degeneracy (the
853                // sample-size gate handles too-small data), and any non-finite
854                // value is left to the dedicated finiteness checks rather than
855                // poisoning the sd, so it is skipped here.
856                //
857                // Exception (#1856): a *genuinely* zero-variance response —
858                // every observation bit-for-bit identical — is not the
859                // pathological near-constant case above but the well-posed
860                // degenerate limit. The penalized fit collapses cleanly to the
861                // constant (intercept = the shared value, every smooth shrunk
862                // to zero) and predicts that constant, so it must fit rather
863                // than be rejected. Only a response that *varies* below the sd
864                // floor without being exactly constant keeps the #332
865                // rejection, whose REML score genuinely diverges to +∞.
866                let mut count = 0usize;
867                let mut mean = 0.0f64;
868                for &yi in y.iter() {
869                    if !yi.is_finite() {
870                        return Ok(());
871                    }
872                    count += 1;
873                    mean += yi;
874                }
875                if count < 2 {
876                    return Ok(());
877                }
878                mean /= count as f64;
879                let mut sumsq = 0.0f64;
880                for &yi in y.iter() {
881                    let d = yi - mean;
882                    sumsq += d * d;
883                }
884                let sample_sd = (sumsq / (count as f64 - 1.0)).sqrt();
885                if sample_sd <= GAUSSIAN_MIN_SAMPLE_SD {
886                    // Genuine zero variance (all values exactly equal) is the
887                    // well-posed constant limit, not the #332 divergence: accept
888                    // it and let the fitter return the constant surface (#1856).
889                    let first = y[0];
890                    if y.iter().all(|&yi| yi == first) {
891                        return Ok(());
892                    }
893                    return Err(ResponseDegeneracy {
894                        family_label: self.response_support_label(),
895                        kind: ResponseDegeneracyKind::GaussianNearConstant {
896                            sample_sd,
897                            min_sd: GAUSSIAN_MIN_SAMPLE_SD,
898                        },
899                    });
900                }
901                Ok(())
902            }
903            Self::Poisson => {
904                if !y.is_empty() && y.iter().all(|&yi| yi == 0.0) {
905                    Err(ResponseDegeneracy {
906                        family_label: self.response_support_label(),
907                        kind: ResponseDegeneracyKind::PoissonAllZeros,
908                    })
909                } else {
910                    Ok(())
911                }
912            }
913            Self::NegativeBinomial { .. } => {
914                if !y.is_empty() && y.iter().all(|&yi| yi == 0.0) {
915                    Err(ResponseDegeneracy {
916                        family_label: self.response_support_label(),
917                        kind: ResponseDegeneracyKind::NegativeBinomialAllZeros,
918                    })
919                } else {
920                    Ok(())
921                }
922            }
923            Self::Tweedie { .. } | Self::Beta { .. } | Self::Gamma | Self::RoystonParmar => Ok(()),
924        }
925    }
926
927    /// Auto-infer a likelihood family when the user did not specify one.
928    ///
929    /// Policy:
930    ///   * A string-valued (`Categorical`) response column is refused —
931    ///     numeric-encoded level indices (e.g. `"yes"`/`"no"` → `0.0`/`1.0`)
932    ///     would otherwise be silently interpreted as a binary outcome,
933    ///     producing a probability model the user never asked for.
934    ///   * A strictly-binary numeric response (`Binary` kind, or `Numeric`
935    ///     with only `{0, 1}` values) maps to `Binomial`.
936    ///   * A non-negative integer-valued count response (every value finite,
937    ///     `>= 0`, and exactly integer-valued) that reaches
938    ///     beyond the binary `{0, 1}` window (i.e. carries at least one value
939    ///     `>= 2`) maps to `Poisson` (log link). This is the "magic-by-default"
940    ///     count detection: mgcv/statsmodels users expect `0,1,2,3,...` to fit a
941    ///     Poisson GLM, not an identity-link Gaussian.
942    ///   * Anything else (any fractional or negative value) maps to `Gaussian`.
943    ///
944    /// The fallback to `is_binary_response` inside the `Numeric` arm is what
945    /// historically lived directly inside `resolve_family`; centralising the
946    /// policy here means every entry point (formula API, CLI, future bindings)
947    /// gets the same default-inference behaviour.
948    pub fn infer_from_response(
949        y: ArrayView1<'_, f64>,
950        y_kind: ResponseColumnKind,
951    ) -> Result<Self, ResponseInferenceRefusal> {
952        match y_kind {
953            ResponseColumnKind::Categorical { levels } => Err(ResponseInferenceRefusal {
954                reason: ResponseInferenceRefusalReason::NonNumericResponse,
955                levels,
956            }),
957            ResponseColumnKind::Binary => Ok(Self::Binomial),
958            ResponseColumnKind::Numeric => {
959                let binary = !y.is_empty()
960                    && y.iter().all(|v| {
961                        v.is_finite()
962                            && ((*v - 0.0).abs() < BINOMIAL_BINARY_TOL
963                                || (*v - 1.0).abs() < BINOMIAL_BINARY_TOL)
964                    });
965                if binary {
966                    return Ok(Self::Binomial);
967                }
968                // Count signature: every value finite, non-negative, and an
969                // exactly integer, with at least one value
970                // `>= 2` so it is not the (already-handled) binary case and not
971                // a degenerate all-zero column. A single fractional or negative
972                // value disqualifies the whole response, keeping continuous and
973                // signed data on the conservative Gaussian default.
974                let count = !y.is_empty()
975                    && y.iter()
976                        .all(|v| v.is_finite() && *v >= 0.0 && *v == v.round())
977                    && y.iter().any(|v| *v >= 2.0);
978                if count {
979                    Ok(Self::Poisson)
980                } else {
981                    Ok(Self::Gaussian)
982                }
983            }
984        }
985    }
986}
987
988/// Domain-violation detail produced by [`ResponseFamily::validate_response_support`].
989///
990/// Owns its own `Display` impl so call sites in the workflow, the CLI, and the
991/// external-design GLM path produce identical user-facing prose. The
992/// `total_violations` counter is kept distinct from `offending.len()` so the
993/// message can honestly say `(N total)` even when only the first
994/// `MAX_REPORTED` indices are surfaced.
995#[derive(Debug, Clone)]
996pub struct ResponseSupportViolation {
997    pub family_label: &'static str,
998    pub requirement: &'static str,
999    pub offending: Vec<(usize, f64)>,
1000    pub total_violations: usize,
1001}
1002
1003impl ResponseSupportViolation {
1004    /// Maximum number of offending row indices reported in the error message.
1005    /// Keeps the message bounded on large-scale data while still pointing
1006    /// the user at concrete bad rows to inspect.
1007    pub const MAX_REPORTED: usize = 5;
1008
1009    /// Format the violation against a specific response column name. The
1010    /// column name is supplied by the caller because [`ResponseFamily`] does
1011    /// not know which column the user pointed at.
1012    pub fn message_for(&self, response_name: &str) -> String {
1013        let shown = self
1014            .offending
1015            .iter()
1016            .map(|(i, v)| format!("y[{i}]={v}"))
1017            .collect::<Vec<_>>()
1018            .join(", ");
1019        let more = if self.total_violations > self.offending.len() {
1020            format!(", ... ({} total)", self.total_violations)
1021        } else {
1022            String::new()
1023        };
1024        format!(
1025            "{family} family requires {req}; response column '{name}' violates this constraint at row(s) [{shown}{more}]",
1026            family = self.family_label,
1027            req = self.requirement,
1028            name = response_name,
1029        )
1030    }
1031}
1032
1033impl std::fmt::Display for ResponseSupportViolation {
1034    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1035        f.write_str(&self.message_for("y"))
1036    }
1037}
1038
1039impl std::error::Error for ResponseSupportViolation {}
1040
1041/// Absolute tolerance for the exact-`{0, 1}` test that defines the scalar
1042/// Bernoulli (`Binomial`) response support.
1043///
1044/// The scalar `Binomial` family carries no per-row trial count, so its
1045/// log-likelihood is the Bernoulli/soft-label cross-entropy
1046/// `ℓ(η) = y·η − log(1 + eη)`, which is unbounded above for `y ∉ {0, 1}`.
1047/// Both the auto-inference (`infer_from_response`) and degeneracy
1048/// (`validate_response_degeneracy`) paths classify a value as binary by the
1049/// same `1e-12` window; the support check shares this single threshold so the
1050/// three layers agree on exactly which responses are admissible.
1051pub const BINOMIAL_BINARY_TOL: f64 = 1.0e-12;
1052
1053/// Minimum admissible sample standard deviation for a `Gaussian` response.
1054///
1055/// A response whose two-pass, mean-centred sample sd is at or below this
1056/// threshold is *effectively constant* in `f64` arithmetic: the marginal REML
1057/// log-likelihood carries a `−n/2·log σ²` term that diverges to `+∞` as the
1058/// fitted scale `σ → 0`, so the outer objective rejects every seed with
1059/// `reml_score must be finite, got inf` (#332). The bound is chosen well below
1060/// any well-conditioned scientific signal (genuine data has sd many orders of
1061/// magnitude larger) yet above the f64 round-off floor, so it never trips a
1062/// real fit while catching responses that carry no signal (e.g. a column read
1063/// in the wrong scale, or a constant accidentally fed as the response).
1064///
1065/// One case below the floor is *not* rejected: a genuinely zero-variance
1066/// response whose values are all bit-for-bit identical. That is the well-posed
1067/// constant limit (the fit collapses to the constant, smooths shrunk to zero)
1068/// rather than the divergent near-constant case, so it fits (#1856); only a
1069/// response that varies below this floor without being exactly constant is
1070/// rejected.
1071pub const GAUSSIAN_MIN_SAMPLE_SD: f64 = 1.0e-10;
1072
1073/// Round tolerance for recognising an integer-valued (count) response.
1074///
1075/// `infer_from_response` classifies a numeric response as a Poisson count when
1076/// every value is finite, non-negative, and within this window of its nearest
1077/// non-negative integer. The threshold is looser than [`BINOMIAL_BINARY_TOL`]
1078/// because count columns frequently arrive as `f64` round-trips of integers
1079/// (CSV parse, integer→double promotion) that accumulate ULP-scale error well
1080/// above `1e-12`; `1e-9` admits those without ever matching genuinely
1081/// continuous data, whose fractional parts are O(1).
1082
1083/// Classifier for a [`ResponseDegeneracy`]. Each variant carries the family-
1084/// specific evidence the caller needs to format a useful message without
1085/// having to re-derive the diagnostic.
1086#[derive(Debug, Clone)]
1087pub enum ResponseDegeneracyKind {
1088    /// Bernoulli / Binomial response with every observed value equal to 0.
1089    BinomialAllZeros,
1090    /// Bernoulli / Binomial response with every observed value equal to 1.
1091    BinomialAllOnes,
1092    /// Poisson response with no positive counts. The log-rate likelihood has
1093    /// its supremum at η = −∞, not at a finite fitted mode.
1094    PoissonAllZeros,
1095    /// Negative-Binomial response with no positive counts. As for Poisson, the
1096    /// log-rate likelihood has no finite optimum or finite posterior moments.
1097    NegativeBinomialAllZeros,
1098    /// Gaussian response that is effectively constant in `f64` arithmetic
1099    /// (sample standard deviation at or below [`GAUSSIAN_MIN_SAMPLE_SD`]). The
1100    /// marginal REML log-likelihood `−n/2·log σ²` diverges to `+∞` as the
1101    /// fitted scale `σ → 0`, so every outer evaluation rejects with a
1102    /// non-finite score. Carries the observed `sample_sd` and the `min_sd`
1103    /// threshold so the message can quote both verbatim (#332).
1104    GaussianNearConstant {
1105        /// The two-pass, mean-centred sample standard deviation of the response.
1106        sample_sd: f64,
1107        /// The rejection threshold ([`GAUSSIAN_MIN_SAMPLE_SD`]).
1108        min_sd: f64,
1109    },
1110}
1111
1112/// Degenerate-response detail produced by
1113/// [`ResponseFamily::validate_response_degeneracy`].
1114///
1115/// Mirrors [`ResponseSupportViolation`]: it owns its own `Display` and
1116/// `message_for(column_name)` so call sites in the workflow, the CLI, and
1117/// any future binding produce identical user-facing prose without coupling
1118/// each one to the family-internal classifier.
1119#[derive(Debug, Clone)]
1120pub struct ResponseDegeneracy {
1121    pub family_label: &'static str,
1122    pub kind: ResponseDegeneracyKind,
1123}
1124
1125impl ResponseDegeneracy {
1126    /// Format the degeneracy against a specific response column name. The
1127    /// column name is supplied by the caller because [`ResponseFamily`] does
1128    /// not know which column the user pointed at.
1129    pub fn message_for(&self, response_name: &str) -> String {
1130        match self.kind {
1131            ResponseDegeneracyKind::BinomialAllZeros => format!(
1132                "{family} response '{name}' is degenerate: all values are 0 (no events). \
1133                 The maximum-likelihood logit is −∞ at this boundary, so the REML score \
1134                 is not finite. Fix: ensure the response contains at least one 0 and \
1135                 at least one 1 (e.g. drop the offending subgroup, or refit on a pooled \
1136                 sample that includes both classes).",
1137                family = self.family_label,
1138                name = response_name,
1139            ),
1140            ResponseDegeneracyKind::BinomialAllOnes => format!(
1141                "{family} response '{name}' is degenerate: all values are 1 (no non-events). \
1142                 The maximum-likelihood logit is +∞ at this boundary, so the REML score \
1143                 is not finite. Fix: ensure the response contains at least one 0 and \
1144                 at least one 1 (e.g. drop the offending subgroup, or refit on a pooled \
1145                 sample that includes both classes).",
1146                family = self.family_label,
1147                name = response_name,
1148            ),
1149            ResponseDegeneracyKind::PoissonAllZeros
1150            | ResponseDegeneracyKind::NegativeBinomialAllZeros => format!(
1151                "{family} response '{name}' is degenerate: all counts are 0. \
1152                 The log-rate likelihood is maximized only as η → −∞, so there is no \
1153                 finite fitted mode or finite posterior mean/variance to report. Fix: \
1154                 ensure the response contains at least one positive count (for example, \
1155                 drop the empty subgroup or pool it with observations containing events).",
1156                family = self.family_label,
1157                name = response_name,
1158            ),
1159            ResponseDegeneracyKind::GaussianNearConstant { sample_sd, min_sd } => format!(
1160                "{family} response '{name}' is effectively constant (sample sd ~ {sample_sd:.3e} \
1161                 <= {min_sd:.0e}); the marginal REML log-likelihood −n/2·log σ² diverges to \
1162                 +∞ as σ → 0. Fix: check the response column units (is it being read in the \
1163                 right scale?), centre/rescale the response, or drop the column if it carries \
1164                 no signal.",
1165                family = self.family_label,
1166                name = response_name,
1167            ),
1168        }
1169    }
1170}
1171
1172impl std::fmt::Display for ResponseDegeneracy {
1173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1174        f.write_str(&self.message_for("y"))
1175    }
1176}
1177
1178impl std::error::Error for ResponseDegeneracy {}
1179
1180/// Caller-supplied description of the response column's *source* kind.
1181///
1182/// `Categorical { levels }` flags a column that arrived as non-numeric strings
1183/// (the ingest layer encoded its levels to `0.0, 1.0, ...` indices) — the
1184/// `levels` list is preserved so the auto-inference refusal can echo them
1185/// back to the user verbatim. `Binary` is the ingest-layer signal that a
1186/// numeric column already contains only `{0, 1}` (used to short-circuit the
1187/// scan inside [`ResponseFamily::infer_from_response`]). `Numeric` is the
1188/// generic continuous case.
1189#[derive(Debug, Clone)]
1190pub enum ResponseColumnKind {
1191    Numeric,
1192    Binary,
1193    Categorical { levels: Vec<String> },
1194}
1195
1196/// Reason [`ResponseFamily::infer_from_response`] refused to pick a default
1197/// family. Kept as an enum so future policy extensions (e.g. "refuse on
1198/// constant response" — currently a separate CLI-side check) can be added
1199/// without breaking the call site's match arms.
1200#[derive(Debug, Clone)]
1201pub enum ResponseInferenceRefusalReason {
1202    NonNumericResponse,
1203}
1204
1205/// Auto-inference refusal carrying the levels seen in the source column so
1206/// the workflow error can echo them in its message.
1207#[derive(Debug, Clone)]
1208pub struct ResponseInferenceRefusal {
1209    pub reason: ResponseInferenceRefusalReason,
1210    pub levels: Vec<String>,
1211}
1212
1213impl ResponseInferenceRefusal {
1214    /// Format the refusal against a specific response column name.
1215    pub fn message_for(&self, response_name: &str) -> String {
1216        match self.reason {
1217            ResponseInferenceRefusalReason::NonNumericResponse => {
1218                let n = self.levels.len().min(5);
1219                let head = self
1220                    .levels
1221                    .iter()
1222                    .take(n)
1223                    .map(|s| format!("'{s}'"))
1224                    .collect::<Vec<_>>()
1225                    .join(", ");
1226                let preview = if self.levels.len() > n {
1227                    format!("[{head}, ...]")
1228                } else {
1229                    format!("[{head}]")
1230                };
1231                format!(
1232                    "response column '{name}' contains non-numeric values {preview}. \
1233                     Did you mean to use family='binomial' for a binary outcome, \
1234                     or does '{name}' contain categorical labels that should be encoded first?",
1235                    name = response_name,
1236                    preview = preview,
1237                )
1238            }
1239        }
1240    }
1241}
1242
1243impl std::fmt::Display for ResponseInferenceRefusal {
1244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1245        f.write_str(&self.message_for("y"))
1246    }
1247}
1248
1249impl std::error::Error for ResponseInferenceRefusal {}
1250
1251/// Unified likelihood specification: response distribution + parameterized link.
1252///
1253/// `ResponseFamily` carries the per-family scalars (Tweedie p, NegBin theta,
1254/// Beta phi); `InverseLink` carries the parameterized link state. Together
1255/// they replace the former flat likelihood enum.
1256///
1257/// Only the legal `(response, link)` cells enumerated by [`LikelihoodSpec::kind`]
1258/// are representable through the public surface: [`LikelihoodSpec::try_new`]
1259/// validates the legal matrix on construction, and deserialization routes
1260/// through [`LikelihoodSpecWire`] (`#[serde(try_from / into)]`) so saved bytes
1261/// cannot resurrect an illegal cell. The on-wire shape is byte-identical to the
1262/// historical `{ response, link }` struct, so legal saved models load unchanged.
1263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1264#[serde(try_from = "LikelihoodSpecWire", into = "LikelihoodSpecWire")]
1265pub struct LikelihoodSpec {
1266    pub response: ResponseFamily,
1267    pub link: InverseLink,
1268}
1269
1270/// Transparent serde shadow of [`LikelihoodSpec`] with the identical wire shape
1271/// (`response`, `link`). All (de)serialization of `LikelihoodSpec` routes
1272/// through this type so the legal-matrix check in
1273/// [`TryFrom<LikelihoodSpecWire>`] runs on every load, closing the
1274/// saved-bytes hole: an illegal `(response, link)` cell deserializes into a
1275/// serde error instead of a silently-masked spec.
1276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1277pub struct LikelihoodSpecWire {
1278    pub response: ResponseFamily,
1279    pub link: InverseLink,
1280}
1281
1282impl From<LikelihoodSpec> for LikelihoodSpecWire {
1283    #[inline]
1284    fn from(spec: LikelihoodSpec) -> Self {
1285        Self {
1286            response: spec.response,
1287            link: spec.link,
1288        }
1289    }
1290}
1291
1292impl TryFrom<LikelihoodSpecWire> for LikelihoodSpec {
1293    type Error = IllegalLikelihoodCell;
1294
1295    #[inline]
1296    fn try_from(wire: LikelihoodSpecWire) -> Result<Self, Self::Error> {
1297        Self::try_new(wire.response, wire.link)
1298    }
1299}
1300
1301/// Error returned when an illegal `(ResponseFamily, InverseLink)` cell is
1302/// presented to [`LikelihoodSpec::try_new`] or surfaced during
1303/// deserialization. Only the cells enumerated by [`LikelihoodSpec::kind`] are
1304/// legal; every other product cell would silently mask a wrong response
1305/// transformation (e.g. `Poisson + Identity` predicting `μ = η`, which can go
1306/// negative).
1307#[derive(Debug, Clone, PartialEq)]
1308pub struct IllegalLikelihoodCell {
1309    pub response: &'static str,
1310    pub link: &'static str,
1311}
1312
1313impl std::fmt::Display for IllegalLikelihoodCell {
1314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1315        write!(
1316            f,
1317            "illegal likelihood cell: response `{}` does not admit inverse link `{}`. \
1318             Each non-binomial family is pinned to one link (Gaussian/Royston-Parmar→identity, \
1319             Poisson/Gamma/Tweedie/Negative-Binomial→log, Beta→logit); the binomial family \
1320             admits logit/probit/cloglog and the latent-cloglog/SAS/beta-logistic/blended \
1321             links, but not identity/log.",
1322            self.response, self.link
1323        )
1324    }
1325}
1326
1327impl std::error::Error for IllegalLikelihoodCell {}
1328
1329/// Legal-only enumeration of the `(ResponseFamily, InverseLink)` cells the
1330/// engine recognises. `LikelihoodSpec` is the product type with ~40 nominal
1331/// cells (8 response variants × 5 inverse-link variants), but only the cells
1332/// listed here are honoured by the family math; the rest are silently masked
1333/// by fallback arms. `FamilySpecKind` is the canonical projection used by
1334/// naming, predicates, and dispatch.
1335#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1336pub enum FamilySpecKind {
1337    GaussianIdentity,
1338    PoissonLog,
1339    GammaLog,
1340    TweedieLog { p: f64 },
1341    NegativeBinomialLog { theta: f64 },
1342    BetaLogit { phi: f64 },
1343    RoystonParmar,
1344    BinomialLogit,
1345    BinomialProbit,
1346    BinomialCLogLog,
1347    BinomialLogLog,
1348    BinomialCauchit,
1349    BinomialLatentCLogLog(LatentCLogLogState),
1350    BinomialSas(SasLinkState),
1351    BinomialBetaLogistic(SasLinkState),
1352    BinomialMixture(MixtureLinkState),
1353}
1354
1355impl FamilySpecKind {
1356    /// Short identifier matching the legacy `LikelihoodSpec::name()` strings.
1357    #[inline]
1358    pub const fn name(&self) -> &'static str {
1359        match self {
1360            Self::GaussianIdentity => "gaussian",
1361            Self::PoissonLog => "poisson-log",
1362            Self::TweedieLog { .. } => "tweedie-log",
1363            Self::NegativeBinomialLog { .. } => "negative-binomial-log",
1364            Self::BetaLogit { .. } => "beta-regression-logit",
1365            Self::GammaLog => "gamma-log",
1366            Self::RoystonParmar => "royston-parmar",
1367            Self::BinomialLogit => "binomial-logit",
1368            Self::BinomialProbit => "binomial-probit",
1369            Self::BinomialCLogLog => "binomial-cloglog",
1370            Self::BinomialLogLog => "binomial-loglog",
1371            Self::BinomialCauchit => "binomial-cauchit",
1372            Self::BinomialLatentCLogLog(_) => "latent-cloglog-binomial",
1373            Self::BinomialSas(_) => "binomial-sas",
1374            Self::BinomialBetaLogistic(_) => "binomial-beta-logistic",
1375            Self::BinomialMixture(_) => "binomial-blended-inverse-link",
1376        }
1377    }
1378
1379    /// Human-readable label matching the legacy `LikelihoodSpec::pretty_name()` strings.
1380    #[inline]
1381    pub const fn pretty_name(&self) -> &'static str {
1382        match self {
1383            Self::GaussianIdentity => "Gaussian Identity",
1384            Self::PoissonLog => "Poisson Log",
1385            Self::TweedieLog { .. } => "Tweedie Log",
1386            Self::NegativeBinomialLog { .. } => "Negative-Binomial Log",
1387            Self::BetaLogit { .. } => "Beta Regression Logit",
1388            Self::GammaLog => "Gamma Log",
1389            Self::RoystonParmar => "Royston Parmar",
1390            Self::BinomialLogit => "Binomial Logit",
1391            Self::BinomialProbit => "Binomial Probit",
1392            Self::BinomialCLogLog => "Binomial CLogLog",
1393            Self::BinomialLogLog => "Binomial LogLog",
1394            Self::BinomialCauchit => "Binomial Cauchit",
1395            Self::BinomialLatentCLogLog(_) => "Latent CLogLog Binomial",
1396            Self::BinomialSas(_) => "Binomial SAS",
1397            Self::BinomialBetaLogistic(_) => "Binomial Beta-Logistic",
1398            Self::BinomialMixture(_) => "Binomial Blended Inverse-Link",
1399        }
1400    }
1401
1402    #[inline]
1403    pub const fn is_binomial(&self) -> bool {
1404        matches!(
1405            self,
1406            Self::BinomialLogit
1407                | Self::BinomialProbit
1408                | Self::BinomialCLogLog
1409                | Self::BinomialLogLog
1410                | Self::BinomialCauchit
1411                | Self::BinomialLatentCLogLog(_)
1412                | Self::BinomialSas(_)
1413                | Self::BinomialBetaLogistic(_)
1414                | Self::BinomialMixture(_)
1415        )
1416    }
1417
1418    #[inline]
1419    pub const fn is_gaussian_identity(&self) -> bool {
1420        matches!(self, Self::GaussianIdentity)
1421    }
1422
1423    #[inline]
1424    pub const fn is_royston_parmar(&self) -> bool {
1425        matches!(self, Self::RoystonParmar)
1426    }
1427
1428    #[inline]
1429    pub const fn is_latent_cloglog(&self) -> bool {
1430        matches!(self, Self::BinomialLatentCLogLog(_))
1431    }
1432
1433    #[inline]
1434    pub const fn is_binomial_mixture(&self) -> bool {
1435        matches!(self, Self::BinomialMixture(_))
1436    }
1437
1438    #[inline]
1439    pub const fn is_binomial_sas(&self) -> bool {
1440        matches!(self, Self::BinomialSas(_))
1441    }
1442
1443    #[inline]
1444    pub const fn is_binomial_beta_logistic(&self) -> bool {
1445        matches!(self, Self::BinomialBetaLogistic(_))
1446    }
1447
1448    /// Coarse kind-level Firth eligibility: every binomial inverse link this
1449    /// enum can represent (Logit/Probit/CLogLog and the stateful
1450    /// LatentCLogLog/SAS/Beta-Logistic/Mixture links) carries a Fisher-weight
1451    /// jet, so kind-level Firth support is exactly binomial membership.
1452    ///
1453    /// The authoritative, link-resolved gate is
1454    /// [`LikelihoodSpec::supports_firth`], which routes through
1455    /// [`InverseLink::has_fisher_weight_jet`]. Keep this in agreement with that
1456    /// predicate: a future binomial link without a Fisher-weight jet would make
1457    /// this approximation diverge and must be handled at both sites.
1458    #[inline]
1459    pub const fn supports_firth(&self) -> bool {
1460        self.is_binomial()
1461    }
1462}
1463
1464impl LikelihoodSpec {
1465    /// Unchecked constructor: assembles a `(response, link)` cell *without*
1466    /// validating the legal matrix. Reserved for the in-crate named const
1467    /// constructors below (`gaussian_identity`, `poisson_log`, `beta_logit`,
1468    /// the `binomial_*` family, …), every one of which builds a cell that is
1469    /// legal by construction. The public, fallible entry point for an arbitrary
1470    /// `(response, link)` pair is [`LikelihoodSpec::try_new`]; the serde path
1471    /// also validates via [`LikelihoodSpecWire`]. Do not expose illegal cells
1472    /// through this method.
1473    #[inline]
1474    pub const fn new(response: ResponseFamily, link: InverseLink) -> Self {
1475        Self { response, link }
1476    }
1477
1478    /// Returns `true` when the `(response, link)` pair is one of the legal cells
1479    /// the family math honours — exactly the cells enumerated by
1480    /// [`LikelihoodSpec::kind`] before any masking. Each non-binomial response
1481    /// is pinned to a single inverse link; the binomial family admits its full
1482    /// set of probability links but never the identity/log standard links.
1483    #[inline]
1484    pub fn is_legal_cell(response: &ResponseFamily, link: &InverseLink) -> bool {
1485        match response {
1486            // Pure-identity families.
1487            ResponseFamily::Gaussian | ResponseFamily::RoystonParmar => {
1488                matches!(link, InverseLink::Standard(StandardLink::Identity))
1489            }
1490            // Log-link families.
1491            ResponseFamily::Poisson
1492            | ResponseFamily::Gamma
1493            | ResponseFamily::Tweedie { .. }
1494            | ResponseFamily::NegativeBinomial { .. } => {
1495                matches!(link, InverseLink::Standard(StandardLink::Log))
1496            }
1497            // Logit-link family.
1498            ResponseFamily::Beta { .. } => {
1499                matches!(link, InverseLink::Standard(StandardLink::Logit))
1500            }
1501            // Binomial admits every probability link except the inert
1502            // identity/log standard links.
1503            ResponseFamily::Binomial => match link {
1504                InverseLink::Standard(
1505                    StandardLink::Logit
1506                    | StandardLink::Probit
1507                    | StandardLink::CLogLog
1508                    | StandardLink::LogLog
1509                    | StandardLink::Cauchit,
1510                ) => true,
1511                InverseLink::Standard(StandardLink::Identity | StandardLink::Log) => false,
1512                InverseLink::LatentCLogLog(_)
1513                | InverseLink::Sas(_)
1514                | InverseLink::BetaLogistic(_)
1515                | InverseLink::Mixture(_) => true,
1516            },
1517        }
1518    }
1519
1520    /// Fallible constructor over an arbitrary `(response, link)` pair. Validates
1521    /// the legal matrix ([`LikelihoodSpec::is_legal_cell`]) so that an illegal
1522    /// cell — one whose stored link would drive a wrong response transformation
1523    /// — is rejected instead of silently masked by [`LikelihoodSpec::kind`].
1524    #[inline]
1525    pub fn try_new(
1526        response: ResponseFamily,
1527        link: InverseLink,
1528    ) -> Result<Self, IllegalLikelihoodCell> {
1529        if Self::is_legal_cell(&response, &link) {
1530            Ok(Self::new(response, link))
1531        } else {
1532            Err(IllegalLikelihoodCell {
1533                response: response.name(),
1534                link: link.link_function().name(),
1535            })
1536        }
1537    }
1538
1539    #[inline]
1540    pub const fn gaussian_identity() -> Self {
1541        Self::new(
1542            ResponseFamily::Gaussian,
1543            InverseLink::Standard(StandardLink::Identity),
1544        )
1545    }
1546
1547    #[inline]
1548    pub const fn binomial_logit() -> Self {
1549        Self::new(
1550            ResponseFamily::Binomial,
1551            InverseLink::Standard(StandardLink::Logit),
1552        )
1553    }
1554
1555    #[inline]
1556    pub const fn binomial_probit() -> Self {
1557        Self::new(
1558            ResponseFamily::Binomial,
1559            InverseLink::Standard(StandardLink::Probit),
1560        )
1561    }
1562
1563    #[inline]
1564    pub const fn binomial_cloglog() -> Self {
1565        Self::new(
1566            ResponseFamily::Binomial,
1567            InverseLink::Standard(StandardLink::CLogLog),
1568        )
1569    }
1570
1571    #[inline]
1572    pub const fn binomial_latent_cloglog(state: LatentCLogLogState) -> Self {
1573        Self::new(ResponseFamily::Binomial, InverseLink::LatentCLogLog(state))
1574    }
1575
1576    #[inline]
1577    pub const fn binomial_sas(state: SasLinkState) -> Self {
1578        Self::new(ResponseFamily::Binomial, InverseLink::Sas(state))
1579    }
1580
1581    #[inline]
1582    pub const fn binomial_beta_logistic(state: SasLinkState) -> Self {
1583        Self::new(ResponseFamily::Binomial, InverseLink::BetaLogistic(state))
1584    }
1585
1586    #[inline]
1587    pub fn binomial_mixture(state: MixtureLinkState) -> Self {
1588        Self::new(ResponseFamily::Binomial, InverseLink::Mixture(state))
1589    }
1590
1591    #[inline]
1592    pub const fn poisson_log() -> Self {
1593        Self::new(
1594            ResponseFamily::Poisson,
1595            InverseLink::Standard(StandardLink::Log),
1596        )
1597    }
1598
1599    #[inline]
1600    pub const fn tweedie_log(p: f64) -> Self {
1601        Self::new(
1602            ResponseFamily::Tweedie { p },
1603            InverseLink::Standard(StandardLink::Log),
1604        )
1605    }
1606
1607    /// Estimated-theta NB spec: `theta` is the seed, refined by the inner
1608    /// solver (#802 default).
1609    #[inline]
1610    pub const fn negative_binomial_log(theta: f64) -> Self {
1611        Self::new(
1612            ResponseFamily::NegativeBinomial {
1613                theta,
1614                theta_fixed: false,
1615            },
1616            InverseLink::Standard(StandardLink::Log),
1617        )
1618    }
1619
1620    /// Fixed-theta NB spec: the fit holds `theta` at exactly this value
1621    /// (`--negative-binomial-theta`, issue #983).
1622    #[inline]
1623    pub const fn negative_binomial_log_fixed(theta: f64) -> Self {
1624        Self::new(
1625            ResponseFamily::NegativeBinomial {
1626                theta,
1627                theta_fixed: true,
1628            },
1629            InverseLink::Standard(StandardLink::Log),
1630        )
1631    }
1632
1633    #[inline]
1634    pub const fn beta_logit(phi: f64) -> Self {
1635        Self::new(
1636            ResponseFamily::Beta { phi },
1637            InverseLink::Standard(StandardLink::Logit),
1638        )
1639    }
1640
1641    #[inline]
1642    pub const fn gamma_log() -> Self {
1643        Self::new(
1644            ResponseFamily::Gamma,
1645            InverseLink::Standard(StandardLink::Log),
1646        )
1647    }
1648
1649    #[inline]
1650    pub const fn royston_parmar() -> Self {
1651        Self::new(
1652            ResponseFamily::RoystonParmar,
1653            InverseLink::Standard(StandardLink::Identity),
1654        )
1655    }
1656
1657    #[inline]
1658    pub const fn link_function(&self) -> LinkFunction {
1659        self.link.link_function()
1660    }
1661
1662    /// Once-and-for-all classification into the legal-only `FamilySpecKind`.
1663    ///
1664    /// `(ResponseFamily, InverseLink)` is a 40-cell product (8 response × 5
1665    /// inverse-link); only the cells listed here are legal. Construction
1666    /// ([`LikelihoodSpec::try_new`]) and deserialization (the
1667    /// [`LikelihoodSpecWire`] `try_from`) both enforce
1668    /// [`LikelihoodSpec::is_legal_cell`], so an illegal cell can never reach
1669    /// this method. Each link-pinned family therefore matches its *one* legal
1670    /// link explicitly; the remaining (now-unreachable) illegal combinations
1671    /// are `unreachable!()` so the historical silent masking — collapsing e.g.
1672    /// `Poisson + Identity` to `PoissonLog` while the transform predicted
1673    /// `μ = η` — can never silently happen again.
1674    pub fn kind(&self) -> FamilySpecKind {
1675        // `legal_cell_kind` returns `Some` for every legal cell and `None`
1676        // for the (by-construction-unreachable) illegal ones. Construction
1677        // (`try_new`) and deserialization (`LikelihoodSpecWire` try_from)
1678        // both enforce `is_legal_cell`, so the `None` branch can never fire
1679        // on a value that exists — `.expect` is the idiomatic loud-on-
1680        // impossible-state assertion (a banned `unreachable!`/`panic!` macro
1681        // would be the same panic with worse provenance). If it ever does
1682        // fire, the message names the offending cell so the silent-masking
1683        // regression this guards against (e.g. `Poisson + Identity`
1684        // collapsing to `PoissonLog`) stays impossible.
1685        self.legal_cell_kind().expect(
1686            "illegal likelihood cell reached kind(): construction (try_new) and \
1687             deserialization (LikelihoodSpecWire) guarantee legality",
1688        )
1689    }
1690
1691    fn legal_cell_kind(&self) -> Option<FamilySpecKind> {
1692        Some(match (&self.response, &self.link) {
1693            (ResponseFamily::Gaussian, InverseLink::Standard(StandardLink::Identity)) => {
1694                FamilySpecKind::GaussianIdentity
1695            }
1696            (ResponseFamily::RoystonParmar, InverseLink::Standard(StandardLink::Identity)) => {
1697                FamilySpecKind::RoystonParmar
1698            }
1699            (ResponseFamily::Poisson, InverseLink::Standard(StandardLink::Log)) => {
1700                FamilySpecKind::PoissonLog
1701            }
1702            (ResponseFamily::Gamma, InverseLink::Standard(StandardLink::Log)) => {
1703                FamilySpecKind::GammaLog
1704            }
1705            (ResponseFamily::Tweedie { p }, InverseLink::Standard(StandardLink::Log)) => {
1706                FamilySpecKind::TweedieLog { p: *p }
1707            }
1708            (
1709                ResponseFamily::NegativeBinomial { theta, .. },
1710                InverseLink::Standard(StandardLink::Log),
1711            ) => FamilySpecKind::NegativeBinomialLog { theta: *theta },
1712            (ResponseFamily::Beta { phi }, InverseLink::Standard(StandardLink::Logit)) => {
1713                FamilySpecKind::BetaLogit { phi: *phi }
1714            }
1715            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Logit)) => {
1716                FamilySpecKind::BinomialLogit
1717            }
1718            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Probit)) => {
1719                FamilySpecKind::BinomialProbit
1720            }
1721            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::CLogLog)) => {
1722                FamilySpecKind::BinomialCLogLog
1723            }
1724            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::LogLog)) => {
1725                FamilySpecKind::BinomialLogLog
1726            }
1727            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Cauchit)) => {
1728                FamilySpecKind::BinomialCauchit
1729            }
1730            (ResponseFamily::Binomial, InverseLink::LatentCLogLog(state)) => {
1731                FamilySpecKind::BinomialLatentCLogLog(*state)
1732            }
1733            (ResponseFamily::Binomial, InverseLink::Sas(state)) => {
1734                FamilySpecKind::BinomialSas(*state)
1735            }
1736            (ResponseFamily::Binomial, InverseLink::BetaLogistic(state)) => {
1737                FamilySpecKind::BinomialBetaLogistic(*state)
1738            }
1739            (ResponseFamily::Binomial, InverseLink::Mixture(state)) => {
1740                FamilySpecKind::BinomialMixture(state.clone())
1741            }
1742            // Every remaining product cell is illegal. `try_new` /
1743            // `LikelihoodSpecWire::try_from` reject these, so construction and
1744            // deserialization guarantee they are unreachable here; `None`
1745            // surfaces that to `kind()`, which aborts loudly via `.expect`
1746            // rather than misclassify the family (a wrong `FamilySpecKind`
1747            // would silently corrupt every downstream likelihood/gradient
1748            // evaluation). A banned `panic!`/`unreachable!` macro would be the
1749            // same divergence with worse provenance.
1750            _ => return None,
1751        })
1752    }
1753
1754    #[inline]
1755    pub fn is_binomial(&self) -> bool {
1756        self.kind().is_binomial()
1757    }
1758
1759    #[inline]
1760    pub fn is_gaussian_identity(&self) -> bool {
1761        self.kind().is_gaussian_identity()
1762    }
1763
1764    #[inline]
1765    pub fn is_royston_parmar(&self) -> bool {
1766        self.kind().is_royston_parmar()
1767    }
1768
1769    #[inline]
1770    pub fn is_latent_cloglog(&self) -> bool {
1771        self.kind().is_latent_cloglog()
1772    }
1773
1774    #[inline]
1775    pub fn is_binomial_mixture(&self) -> bool {
1776        self.kind().is_binomial_mixture()
1777    }
1778
1779    #[inline]
1780    pub fn is_binomial_sas(&self) -> bool {
1781        self.kind().is_binomial_sas()
1782    }
1783
1784    #[inline]
1785    pub fn is_binomial_beta_logistic(&self) -> bool {
1786        self.kind().is_binomial_beta_logistic()
1787    }
1788
1789    /// Default scale metadata for this (response, link).
1790    #[inline]
1791    pub fn default_scale_metadata(&self) -> LikelihoodScaleMetadata {
1792        match &self.response {
1793            ResponseFamily::Gaussian => LikelihoodScaleMetadata::ProfiledGaussian,
1794            ResponseFamily::Gamma => LikelihoodScaleMetadata::EstimatedGammaShape { shape: 1.0 },
1795            // Binomial and Poisson have `phi ≡ 1` (variance fully pinned by the
1796            // mean), so a fixed unit dispersion is correct.
1797            ResponseFamily::Binomial | ResponseFamily::Poisson => {
1798                LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 }
1799            }
1800            // Negative-Binomial's overdispersion `theta` (`Var(y)=mu+mu^2/theta`)
1801            // is a genuine free parameter estimated jointly with the mean by
1802            // default — the family-variant `theta` is only the seed, refined from
1803            // the converged-η ML score during fitting, exactly like the Gamma
1804            // shape / Beta precision / Tweedie φ. Freezing it at the seed made
1805            // every variance-derived output (coefficient/η SEs, Wald and credible
1806            // intervals, predictive intervals, `generate` draws) ignore the
1807            // data's overdispersion (issue #802). `phi` itself stays `≡ 1`.
1808            //
1809            // A user-supplied `--negative-binomial-theta` is the opposite
1810            // contract (issue #983): `theta_fixed = true` routes to the
1811            // non-estimated scale variant, so the inner solver's refresh gate
1812            // (`negbin_theta_is_estimated()`) stays closed and the fit honours
1813            // the held value everywhere it enters.
1814            ResponseFamily::NegativeBinomial { theta, theta_fixed } => {
1815                if *theta_fixed {
1816                    LikelihoodScaleMetadata::FixedNegBinTheta { theta: *theta }
1817                } else {
1818                    LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: *theta }
1819                }
1820            }
1821            // Tweedie's dispersion `phi` is a genuine free parameter
1822            // (`Var(y) = phi · mu^p`) and is estimated jointly with the mean by
1823            // default, exactly like the Gamma shape and Beta precision. The seed
1824            // `phi = 1` is refined from the converged-η Pearson residuals during
1825            // fitting (issue #771). Freezing it at 1 made every variance-derived
1826            // output (SEs, intervals, generate draws) ignore the data's spread.
1827            ResponseFamily::Tweedie { .. } => {
1828                LikelihoodScaleMetadata::EstimatedTweediePhi { phi: 1.0 }
1829            }
1830            // Beta precision is estimated jointly with the mean by default
1831            // (magic-by-default, issue #567): the family-variant `phi` is the
1832            // seed, refined from the working residuals during fitting.
1833            ResponseFamily::Beta { phi } => LikelihoodScaleMetadata::EstimatedBetaPhi { phi: *phi },
1834            ResponseFamily::RoystonParmar => LikelihoodScaleMetadata::Unspecified,
1835        }
1836    }
1837
1838    /// Human-readable label, routed through `FamilySpecKind`.
1839    #[inline]
1840    pub fn pretty_name(&self) -> &'static str {
1841        self.kind().pretty_name()
1842    }
1843
1844    /// Short identifier, routed through `FamilySpecKind`.
1845    #[inline]
1846    pub fn name(&self) -> &'static str {
1847        self.kind().name()
1848    }
1849
1850    #[inline]
1851    pub fn supports_firth(&self) -> bool {
1852        matches!(self.response, ResponseFamily::Binomial) && self.link.has_fisher_weight_jet()
1853    }
1854
1855    /// Family-level fixed-dispersion contract. Returns the dispersion parameter
1856    /// `phi` that the GLM log-likelihood / weight expressions treat as fixed
1857    /// for the given `ResponseFamily`, or `None` when the family carries no
1858    /// fixed scale (profiled or jointly estimated).
1859    ///
1860    /// - `Gaussian` and `Gamma` profile/estimate the scale jointly with the
1861    ///   mean, so no fixed `phi` is exposed here.
1862    /// - `Binomial` and `Poisson` are unit-scale exponential-family fits, so the
1863    ///   contract is `Some(1.0)`. NegativeBinomial's overdispersion lives in
1864    ///   `theta` (a separate parameter / flag), not in a free `phi`, so it also
1865    ///   returns `Some(1.0)`.
1866    /// - `Tweedie { p }` carries its variance power on the family variant. Its
1867    ///   free dispersion `phi` lives in `LikelihoodScaleMetadata` and is
1868    ///   estimated by default (`EstimatedTweediePhi`, issue #771), so this
1869    ///   family-level contract only exposes the unit seed used when callers ask
1870    ///   the response family without scale metadata.
1871    /// - `Beta { phi }` carries its precision parameter directly on the family
1872    ///   variant; the contract returns that exact value rather than the
1873    ///   placeholder used elsewhere for unit-scale GLMs.
1874    /// - `RoystonParmar` has no GLM-style dispersion slot.
1875    #[inline]
1876    pub const fn fixed_dispersion(&self) -> Option<f64> {
1877        match self.response {
1878            ResponseFamily::Gaussian | ResponseFamily::Gamma | ResponseFamily::RoystonParmar => {
1879                None
1880            }
1881            ResponseFamily::Binomial
1882            | ResponseFamily::Poisson
1883            | ResponseFamily::Tweedie { .. }
1884            | ResponseFamily::NegativeBinomial { .. } => Some(1.0),
1885            ResponseFamily::Beta { phi } => Some(phi),
1886        }
1887    }
1888}
1889
1890#[inline]
1891pub const fn is_valid_tweedie_power(p: f64) -> bool {
1892    p.is_finite() && p > 1.0 && p < 2.0
1893}
1894
1895/// Error returned when an `InverseLink` cannot be paired with a particular
1896/// response family because the link is structurally unsupported for that
1897/// family. Carries the link name so call sites can produce a useful message
1898/// without losing the offending variant.
1899#[derive(Debug, Clone, PartialEq, Eq)]
1900pub struct UnsupportedLinkError {
1901    pub family: &'static str,
1902    pub link_name: String,
1903}
1904
1905impl UnsupportedLinkError {
1906    /// Construct an `UnsupportedLinkError` tagged with the response-family
1907    /// name (`"binomial"`, `"gaussian"`, ...) and a printable name for the
1908    /// offending `InverseLink` variant (extracted via the module-private
1909    /// `inverse_link_diagnostic_name`). No allocation beyond the link name.
1910    #[inline]
1911    pub fn new(family: &'static str, link: &InverseLink) -> Self {
1912        Self {
1913            family,
1914            link_name: inverse_link_diagnostic_name(link),
1915        }
1916    }
1917}
1918
1919impl std::fmt::Display for UnsupportedLinkError {
1920    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1921        write!(
1922            f,
1923            "inverse link `{}` is not supported by the {} response family",
1924            self.link_name, self.family
1925        )
1926    }
1927}
1928
1929impl std::error::Error for UnsupportedLinkError {}
1930
1931#[inline]
1932pub fn inverse_link_diagnostic_name(link: &InverseLink) -> String {
1933    match link {
1934        InverseLink::Standard(lf) => lf.name().to_string(),
1935        InverseLink::LatentCLogLog(_) => "latent-cloglog".to_string(),
1936        InverseLink::Sas(_) => "sas".to_string(),
1937        InverseLink::BetaLogistic(_) => "beta-logistic".to_string(),
1938        InverseLink::Mixture(_) => "mixture".to_string(),
1939    }
1940}
1941
1942/// Resolve a binomial-flavoured `LikelihoodSpec` from an `InverseLink`.
1943///
1944/// `StandardLink::Logit | Probit | CLogLog` and the state-bearing
1945/// `LatentCLogLog / Sas / BetaLogistic / Mixture` variants are accepted as
1946/// binomial-compatible. `StandardLink::Log | Identity` have no canonical
1947/// binomial meaning and return `UnsupportedLinkError`. Since
1948/// `InverseLink::Standard` carries `StandardLink` (not `LinkFunction`), the
1949/// previously-required `Standard(LinkFunction::Sas | BetaLogistic)` arm is
1950/// structurally impossible and has been removed.
1951#[inline]
1952pub fn inverse_link_to_binomial_spec(
1953    link: &InverseLink,
1954) -> Result<LikelihoodSpec, UnsupportedLinkError> {
1955    match link {
1956        InverseLink::Standard(StandardLink::Logit)
1957        | InverseLink::Standard(StandardLink::Probit)
1958        | InverseLink::Standard(StandardLink::CLogLog)
1959        | InverseLink::Standard(StandardLink::LogLog)
1960        | InverseLink::Standard(StandardLink::Cauchit) => {
1961            Ok(LikelihoodSpec::new(ResponseFamily::Binomial, link.clone()))
1962        }
1963        InverseLink::LatentCLogLog(_)
1964        | InverseLink::Sas(_)
1965        | InverseLink::BetaLogistic(_)
1966        | InverseLink::Mixture(_) => {
1967            Ok(LikelihoodSpec::new(ResponseFamily::Binomial, link.clone()))
1968        }
1969        InverseLink::Standard(StandardLink::Log)
1970        | InverseLink::Standard(StandardLink::Identity) => {
1971            Err(UnsupportedLinkError::new("binomial", link))
1972        }
1973    }
1974}
1975
1976/// How a likelihood's scale parameter is handled by the fit/result contract.
1977#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1978pub enum LikelihoodScaleMetadata {
1979    /// Gaussian identity fits profile sigma outside the fixed-scale GLM machinery.
1980    ProfiledGaussian,
1981    /// Fixed exponential-dispersion parameter `phi`.
1982    FixedDispersion { phi: f64 },
1983    /// Fixed Gamma shape `k`, equivalent to `phi = 1 / k`.
1984    FixedGammaShape { shape: f64 },
1985    /// Gamma shape `k` estimated jointly with the mean model.
1986    EstimatedGammaShape { shape: f64 },
1987    /// Beta-regression precision `phi` estimated jointly with the mean model.
1988    /// `Var(y) = mu(1-mu)/(1+phi)`; larger `phi` means less noise. Estimated
1989    /// from the working residuals after each mean fit and refreshed across outer
1990    /// iterations, exactly like the Gamma shape (issue #567).
1991    EstimatedBetaPhi { phi: f64 },
1992    /// Beta-regression precision `phi` held FIXED for the duration of the
1993    /// smoothing-parameter (λ) search (#2369). Identical role to
1994    /// `EstimatedBetaPhi` in every weight / variance / covariance expression
1995    /// (`Var(y) = mu(1-mu)/(1+phi)`, the digamma mean score reads `phi` through
1996    /// the same `Beta { phi }` family variant), but the inner solver's
1997    /// per-solve Pearson refresh is gated off (its guard is
1998    /// `beta_phi_is_estimated()`, which `FixedBetaPhi` does not satisfy). The
1999    /// fixed/estimated split mirrors `FixedGammaShape` vs `EstimatedGammaShape`
2000    /// and `FixedNegBinTheta` vs `EstimatedNegBinTheta`. This is a *transient*
2001    /// λ-search form produced only by
2002    /// `with_beta_phi_frozen_for_search`; the single final reported fit
2003    /// Pearson-refreshes `phi` at the converged η and records `EstimatedBetaPhi`.
2004    FixedBetaPhi { phi: f64 },
2005    /// Tweedie exponential-dispersion `phi` estimated jointly with the mean
2006    /// model. `Var(y) = phi · mu^p` with `phi` a genuine free parameter (unlike
2007    /// Binomial/Poisson, where `phi ≡ 1`). Estimated by the Pearson moment
2008    /// estimator `phî = Σ wᵢ (yᵢ − μᵢ)² / μᵢ^p / Σ wᵢ` at the converged η and
2009    /// refreshed across outer iterations, exactly like the Gamma shape and the
2010    /// Beta precision. `phi` enters the IRLS working weight `prior·μ^{2−p}/phi`,
2011    /// so the coefficient covariance `Vb = H⁻¹` already scales as `phi` and the
2012    /// reported SEs track `√phi` (issue #771).
2013    EstimatedTweediePhi { phi: f64 },
2014    /// Negative-Binomial overdispersion `theta` estimated jointly with the mean
2015    /// model. `Var(y) = mu + mu^2 / theta`; larger `theta` means less
2016    /// overdispersion (the Poisson limit is `theta → ∞`). Estimated by the
2017    /// maximum-likelihood `theta` score
2018    /// `Σ wᵢ[ψ(yᵢ+θ) − ψ(θ) + lnθ + 1 − ln(θ+μᵢ) − (yᵢ+θ)/(μᵢ+θ)] = 0` at the
2019    /// converged η (MASS `glm.nb`'s `theta.ml`) and refreshed across outer
2020    /// iterations, exactly like the Gamma shape / Beta precision / Tweedie φ.
2021    /// Unlike those, `theta` is *not* a dispersion scale `phi`: it enters only
2022    /// the IRLS working weight `W = μθ/(θ+μ)` (the full NB2 Fisher information),
2023    /// so the stored penalized Hessian is already the true one and the
2024    /// coefficient covariance `Vb = H⁻¹` takes no post-hoc multiply — `phi ≡ 1`
2025    /// for NB, the overdispersion lives in the variance function. The `theta`
2026    /// carried here mirrors `ResponseFamily::NegativeBinomial { theta }` (the
2027    /// canonical store every weight/deviance expression reads), kept in sync by
2028    /// `with_negbin_theta`, exactly as `EstimatedBetaPhi` mirrors `Beta { phi }`
2029    /// (issue #802).
2030    EstimatedNegBinTheta { theta: f64 },
2031    /// Negative-Binomial overdispersion `theta` held fixed at a user-supplied
2032    /// value (`--negative-binomial-theta`, issue #983). Identical role to
2033    /// `EstimatedNegBinTheta` in every weight / variance / covariance
2034    /// expression (`W = μθ/(θ+μ)`, `Var(y) = μ + μ²/θ`, `phi ≡ 1`), but the
2035    /// inner solver's ML refresh is gated off: the recorded `theta` is the
2036    /// user's, by construction. The fixed/estimated split mirrors
2037    /// `FixedGammaShape` vs `EstimatedGammaShape`.
2038    FixedNegBinTheta { theta: f64 },
2039    /// The engine does not expose fixed-scale semantics for this family.
2040    /// Family has no scalar GLM scale by model definition (currently only
2041    /// Royston-Parmar). This is not a missing-value fallback.
2042    Unspecified,
2043}
2044
2045impl LikelihoodScaleMetadata {
2046    #[inline]
2047    pub const fn fixed_phi(self) -> Option<f64> {
2048        match self {
2049            Self::FixedDispersion { phi }
2050            | Self::EstimatedBetaPhi { phi }
2051            | Self::FixedBetaPhi { phi }
2052            | Self::EstimatedTweediePhi { phi } => Some(phi),
2053            Self::FixedGammaShape { shape } | Self::EstimatedGammaShape { shape } => {
2054                Some(1.0 / shape)
2055            }
2056            // NB's dispersion scale is `phi ≡ 1` (the overdispersion is carried
2057            // by `theta` inside the variance function, not a scale multiply), so
2058            // the fixed-`phi` contract is `Some(1.0)` — NOT `theta`.
2059            Self::EstimatedNegBinTheta { .. } | Self::FixedNegBinTheta { .. } => Some(1.0),
2060            Self::ProfiledGaussian | Self::Unspecified => None,
2061        }
2062    }
2063
2064    /// Whether the Negative-Binomial overdispersion `theta` is estimated from
2065    /// data (the default for NB families, issue #802).
2066    #[inline]
2067    pub const fn negbin_theta_is_estimated(self) -> bool {
2068        matches!(self, Self::EstimatedNegBinTheta { .. })
2069    }
2070
2071    /// The Negative-Binomial `theta` carried in the scale metadata (estimated
2072    /// or user-fixed), or `None` for non-NB families.
2073    #[inline]
2074    pub const fn negbin_theta(self) -> Option<f64> {
2075        match self {
2076            Self::EstimatedNegBinTheta { theta } | Self::FixedNegBinTheta { theta } => Some(theta),
2077            _ => None,
2078        }
2079    }
2080
2081    /// Whether the Beta-regression precision `phi` is estimated from data.
2082    #[inline]
2083    pub const fn beta_phi_is_estimated(self) -> bool {
2084        matches!(self, Self::EstimatedBetaPhi { .. })
2085    }
2086
2087    /// Whether the Tweedie exponential-dispersion `phi` is estimated from data.
2088    #[inline]
2089    pub const fn tweedie_phi_is_estimated(self) -> bool {
2090        matches!(self, Self::EstimatedTweediePhi { .. })
2091    }
2092
2093    #[inline]
2094    pub const fn gamma_shape(self) -> Option<f64> {
2095        match self {
2096            Self::FixedGammaShape { shape } | Self::EstimatedGammaShape { shape } => Some(shape),
2097            _ => None,
2098        }
2099    }
2100
2101    #[inline]
2102    pub const fn gamma_shape_is_estimated(self) -> bool {
2103        matches!(self, Self::EstimatedGammaShape { .. })
2104    }
2105}
2106
2107/// Positive finite likelihood-scale scalar with its stable log coordinate.
2108///
2109/// The raw value is retained exactly for ordinary arithmetic while the log is
2110/// computed once during validation. Consumers that only need log-density
2111/// algebra never materialize a reciprocal (notably Gamma `log(shape)` when the
2112/// input contract is a fixed dispersion `phi`).
2113#[derive(Debug, Clone, Copy, PartialEq)]
2114pub struct PositiveLikelihoodScale {
2115    value: f64,
2116    log_value: f64,
2117}
2118
2119impl PositiveLikelihoodScale {
2120    fn try_new(value: f64, name: &str) -> Result<Self, InvalidLikelihoodScale> {
2121        if !(value.is_finite() && value > 0.0) {
2122            return Err(InvalidLikelihoodScale::new(format!(
2123                "{name} must be finite and strictly positive, got {value:?}"
2124            )));
2125        }
2126        let log_value = value.ln();
2127        if !log_value.is_finite() {
2128            return Err(InvalidLikelihoodScale::new(format!(
2129                "log({name}) is not representable for {value:?}: {log_value:?}"
2130            )));
2131        }
2132        Ok(Self { value, log_value })
2133    }
2134
2135    #[inline]
2136    pub const fn value(self) -> f64 {
2137        self.value
2138    }
2139
2140    #[inline]
2141    pub const fn log_value(self) -> f64 {
2142        self.log_value
2143    }
2144}
2145
2146/// Gamma may be resolved from a shape directly or from a fixed dispersion
2147/// `phi = 1 / shape`. Keeping the provenance avoids an eager reciprocal that
2148/// can overflow even though `log(shape) = -log(phi)` remains representable.
2149#[derive(Debug, Clone, Copy, PartialEq)]
2150pub enum ResolvedGammaScale {
2151    Shape(PositiveLikelihoodScale),
2152    Dispersion(PositiveLikelihoodScale),
2153}
2154
2155/// Validated, family-aware likelihood-scale ownership.
2156///
2157/// Unlike [`LikelihoodScaleMetadata`] alone, this enum is constructed jointly
2158/// with [`ResponseFamily`]. A value therefore proves both scalar validity and
2159/// family/metadata agreement; downstream kernels never need to invent a unit
2160/// fallback for a missing or mismatched scale.
2161#[derive(Debug, Clone, Copy, PartialEq)]
2162pub enum ResolvedLikelihoodScale {
2163    ProfiledGaussian,
2164    FixedGaussian {
2165        phi: PositiveLikelihoodScale,
2166    },
2167    Unit,
2168    Gamma {
2169        scale: ResolvedGammaScale,
2170        estimated: bool,
2171    },
2172    Tweedie {
2173        phi: PositiveLikelihoodScale,
2174        estimated: bool,
2175    },
2176    BetaPrecision {
2177        precision: PositiveLikelihoodScale,
2178        estimated: bool,
2179    },
2180    NegativeBinomial {
2181        theta: PositiveLikelihoodScale,
2182        estimated: bool,
2183    },
2184    Unspecified,
2185}
2186
2187impl ResolvedLikelihoodScale {
2188    fn wrong_family(self, expected: &str) -> InvalidLikelihoodScale {
2189        InvalidLikelihoodScale::new(format!(
2190            "resolved likelihood scale {self:?} does not carry {expected}"
2191        ))
2192    }
2193
2194    /// `log(shape)` for Gamma without reciprocal materialization.
2195    pub fn gamma_log_shape(self) -> Result<f64, InvalidLikelihoodScale> {
2196        match self {
2197            Self::Gamma {
2198                scale: ResolvedGammaScale::Shape(shape),
2199                ..
2200            } => Ok(shape.log_value()),
2201            Self::Gamma {
2202                scale: ResolvedGammaScale::Dispersion(phi),
2203                ..
2204            } => Ok(-phi.log_value()),
2205            other => Err(other.wrong_family("a Gamma shape")),
2206        }
2207    }
2208
2209    /// Representable Gamma shape. A subnormal fixed dispersion can have a
2210    /// finite log-shape but a reciprocal beyond `f64`; that is rejected here,
2211    /// at the raw-shape consumer, rather than earlier in log-density code.
2212    pub fn gamma_shape(self) -> Result<f64, InvalidLikelihoodScale> {
2213        match self {
2214            Self::Gamma {
2215                scale: ResolvedGammaScale::Shape(shape),
2216                ..
2217            } => Ok(shape.value()),
2218            Self::Gamma {
2219                scale: ResolvedGammaScale::Dispersion(phi),
2220                ..
2221            } => {
2222                let shape = 1.0 / phi.value();
2223                if shape.is_finite() && shape > 0.0 {
2224                    Ok(shape)
2225                } else {
2226                    Err(InvalidLikelihoodScale::new(format!(
2227                        "Gamma shape 1 / phi is not representable for phi={:?}: {shape:?}",
2228                        phi.value()
2229                    )))
2230                }
2231            }
2232            other => Err(other.wrong_family("a Gamma shape")),
2233        }
2234    }
2235
2236    pub fn gamma_phi(self) -> Result<f64, InvalidLikelihoodScale> {
2237        match self {
2238            Self::Gamma {
2239                scale: ResolvedGammaScale::Dispersion(phi),
2240                ..
2241            } => Ok(phi.value()),
2242            Self::Gamma {
2243                scale: ResolvedGammaScale::Shape(shape),
2244                ..
2245            } => {
2246                let phi = 1.0 / shape.value();
2247                if phi.is_finite() && phi > 0.0 {
2248                    Ok(phi)
2249                } else {
2250                    Err(InvalidLikelihoodScale::new(format!(
2251                        "Gamma dispersion 1 / shape is not representable for shape={:?}: {phi:?}",
2252                        shape.value()
2253                    )))
2254                }
2255            }
2256            other => Err(other.wrong_family("a Gamma dispersion")),
2257        }
2258    }
2259
2260    pub fn tweedie_log_phi(self) -> Result<f64, InvalidLikelihoodScale> {
2261        match self {
2262            Self::Tweedie { phi, .. } => Ok(phi.log_value()),
2263            other => Err(other.wrong_family("a Tweedie dispersion")),
2264        }
2265    }
2266
2267    pub fn tweedie_phi(self) -> Result<f64, InvalidLikelihoodScale> {
2268        match self {
2269            Self::Tweedie { phi, .. } => Ok(phi.value()),
2270            other => Err(other.wrong_family("a Tweedie dispersion")),
2271        }
2272    }
2273
2274    pub fn negative_binomial_log_theta(self) -> Result<f64, InvalidLikelihoodScale> {
2275        match self {
2276            Self::NegativeBinomial { theta, .. } => Ok(theta.log_value()),
2277            other => Err(other.wrong_family("a negative-binomial theta")),
2278        }
2279    }
2280
2281    pub fn negative_binomial_theta(self) -> Result<f64, InvalidLikelihoodScale> {
2282        match self {
2283            Self::NegativeBinomial { theta, .. } => Ok(theta.value()),
2284            other => Err(other.wrong_family("a negative-binomial theta")),
2285        }
2286    }
2287
2288    pub fn beta_log_precision(self) -> Result<f64, InvalidLikelihoodScale> {
2289        match self {
2290            Self::BetaPrecision { precision, .. } => Ok(precision.log_value()),
2291            other => Err(other.wrong_family("a Beta precision")),
2292        }
2293    }
2294
2295    pub fn beta_precision(self) -> Result<f64, InvalidLikelihoodScale> {
2296        match self {
2297            Self::BetaPrecision { precision, .. } => Ok(precision.value()),
2298            other => Err(other.wrong_family("a Beta precision")),
2299        }
2300    }
2301
2302    pub fn gaussian_log_phi(self) -> Result<f64, InvalidLikelihoodScale> {
2303        match self {
2304            Self::FixedGaussian { phi } => Ok(phi.log_value()),
2305            other => Err(other.wrong_family("a fixed Gaussian dispersion")),
2306        }
2307    }
2308
2309    pub fn gaussian_phi(self) -> Result<f64, InvalidLikelihoodScale> {
2310        match self {
2311            Self::FixedGaussian { phi } => Ok(phi.value()),
2312            other => Err(other.wrong_family("a fixed Gaussian dispersion")),
2313        }
2314    }
2315}
2316
2317/// Failure to resolve family and scale metadata into one likelihood contract.
2318#[derive(Debug, Clone, PartialEq, Eq)]
2319pub struct InvalidLikelihoodScale {
2320    reason: String,
2321}
2322
2323impl InvalidLikelihoodScale {
2324    fn new(reason: String) -> Self {
2325        Self { reason }
2326    }
2327
2328    pub fn reason(&self) -> &str {
2329        &self.reason
2330    }
2331}
2332
2333impl std::fmt::Display for InvalidLikelihoodScale {
2334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2335        write!(f, "invalid resolved likelihood scale: {}", self.reason)
2336    }
2337}
2338
2339impl std::error::Error for InvalidLikelihoodScale {}
2340
2341/// Whether a stored log-likelihood includes response-only normalization constants.
2342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2343pub enum LogLikelihoodNormalization {
2344    Full,
2345    OmittingResponseConstants,
2346    UserProvided,
2347}
2348
2349/// Explicit GLM likelihood specification: response/link spec plus scale semantics.
2350///
2351/// `spec` is the canonical `(ResponseFamily, InverseLink)` selector. `scale`
2352/// records how the scale parameter is handled (profiled Gaussian sigma, fixed
2353/// dispersion, fixed/estimated Gamma shape). The Gamma shape is mutated in
2354/// place during PIRLS via `with_gamma_shape`; preserving that field on this
2355/// struct is what lets the inner solver thread the estimated shape into
2356/// deviance / log-likelihood / weight evaluation without a separate side
2357/// channel.
2358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2359#[serde(try_from = "UncheckedGlmLikelihoodSpec")]
2360pub struct GlmLikelihoodSpec {
2361    pub spec: LikelihoodSpec,
2362    pub scale: LikelihoodScaleMetadata,
2363}
2364
2365/// Serde-only wire representation. Deserialization must pass through
2366/// `GlmLikelihoodSpec::try_new` so persisted contradictory family/metadata
2367/// pairs never enter the runtime as a `GlmLikelihoodSpec`.
2368#[derive(Deserialize)]
2369struct UncheckedGlmLikelihoodSpec {
2370    spec: LikelihoodSpec,
2371    scale: LikelihoodScaleMetadata,
2372}
2373
2374impl TryFrom<UncheckedGlmLikelihoodSpec> for GlmLikelihoodSpec {
2375    type Error = InvalidLikelihoodScale;
2376
2377    fn try_from(unchecked: UncheckedGlmLikelihoodSpec) -> Result<Self, Self::Error> {
2378        Self::try_new(unchecked.spec, unchecked.scale)
2379    }
2380}
2381
2382impl GlmLikelihoodSpec {
2383    /// Construct a likelihood only when family and scale metadata jointly form
2384    /// one valid scalar-ownership contract.
2385    pub fn try_new(
2386        spec: LikelihoodSpec,
2387        scale: LikelihoodScaleMetadata,
2388    ) -> Result<Self, InvalidLikelihoodScale> {
2389        let likelihood = Self { spec, scale };
2390        likelihood.resolved_scale()?;
2391        Ok(likelihood)
2392    }
2393
2394    /// Build a `GlmLikelihoodSpec` from a `LikelihoodSpec`, deriving the
2395    /// canonical default scale metadata for the response family.
2396    #[inline]
2397    pub fn canonical(spec: LikelihoodSpec) -> Self {
2398        let scale = spec.default_scale_metadata();
2399        Self { spec, scale }
2400    }
2401
2402    /// Resolve and validate response-family plus scale metadata atomically.
2403    ///
2404    /// This is the only scale-ownership boundary kernels should consume. It
2405    /// rejects non-positive/non-finite scalars, wrong metadata variants, and
2406    /// duplicated Beta/NB values that disagree bit-for-bit with the family
2407    /// selector.
2408    pub fn resolved_scale(&self) -> Result<ResolvedLikelihoodScale, InvalidLikelihoodScale> {
2409        use LikelihoodScaleMetadata as Metadata;
2410        use ResolvedLikelihoodScale as Resolved;
2411
2412        let positive = |value, name| PositiveLikelihoodScale::try_new(value, name);
2413        let mismatch = |expected: &str| {
2414            InvalidLikelihoodScale::new(format!(
2415                "family {} requires {expected}, got {:?}",
2416                self.spec.response.name(),
2417                self.scale
2418            ))
2419        };
2420
2421        match (&self.spec.response, self.scale) {
2422            (ResponseFamily::Gaussian, Metadata::ProfiledGaussian) => {
2423                Ok(Resolved::ProfiledGaussian)
2424            }
2425            (ResponseFamily::Gaussian, Metadata::FixedDispersion { phi }) => {
2426                Ok(Resolved::FixedGaussian {
2427                    phi: positive(phi, "Gaussian dispersion phi")?,
2428                })
2429            }
2430            (ResponseFamily::Gaussian, _) => {
2431                Err(mismatch("ProfiledGaussian or FixedDispersion metadata"))
2432            }
2433
2434            (
2435                ResponseFamily::Binomial | ResponseFamily::Poisson,
2436                Metadata::FixedDispersion { phi },
2437            ) if phi.to_bits() == 1.0_f64.to_bits() => Ok(Resolved::Unit),
2438            (ResponseFamily::Binomial | ResponseFamily::Poisson, _) => {
2439                Err(mismatch("exact FixedDispersion { phi: 1.0 } metadata"))
2440            }
2441
2442            (ResponseFamily::Gamma, Metadata::FixedGammaShape { shape }) => Ok(Resolved::Gamma {
2443                scale: ResolvedGammaScale::Shape(positive(shape, "Gamma shape")?),
2444                estimated: false,
2445            }),
2446            (ResponseFamily::Gamma, Metadata::EstimatedGammaShape { shape }) => {
2447                Ok(Resolved::Gamma {
2448                    scale: ResolvedGammaScale::Shape(positive(shape, "Gamma shape")?),
2449                    estimated: true,
2450                })
2451            }
2452            (ResponseFamily::Gamma, Metadata::FixedDispersion { phi }) => Ok(Resolved::Gamma {
2453                scale: ResolvedGammaScale::Dispersion(positive(phi, "Gamma dispersion phi")?),
2454                estimated: false,
2455            }),
2456            (ResponseFamily::Gamma, _) => Err(mismatch(
2457                "FixedGammaShape, EstimatedGammaShape, or FixedDispersion metadata",
2458            )),
2459
2460            (ResponseFamily::Tweedie { .. }, Metadata::EstimatedTweediePhi { phi }) => {
2461                Ok(Resolved::Tweedie {
2462                    phi: positive(phi, "Tweedie dispersion phi")?,
2463                    estimated: true,
2464                })
2465            }
2466            (ResponseFamily::Tweedie { .. }, Metadata::FixedDispersion { phi }) => {
2467                Ok(Resolved::Tweedie {
2468                    phi: positive(phi, "Tweedie dispersion phi")?,
2469                    estimated: false,
2470                })
2471            }
2472            (ResponseFamily::Tweedie { .. }, _) => {
2473                Err(mismatch("EstimatedTweediePhi or FixedDispersion metadata"))
2474            }
2475
2476            (ResponseFamily::Beta { phi }, Metadata::EstimatedBetaPhi { phi: metadata_phi }) => {
2477                if phi.to_bits() != metadata_phi.to_bits() {
2478                    return Err(InvalidLikelihoodScale::new(format!(
2479                        "Beta family precision {phi:?} disagrees with metadata precision {metadata_phi:?}"
2480                    )));
2481                }
2482                Ok(Resolved::BetaPrecision {
2483                    precision: positive(*phi, "Beta precision")?,
2484                    estimated: true,
2485                })
2486            }
2487            // The λ-search freeze form (#2369). Same mirror invariant as the
2488            // estimated arm — the frozen `phi` is written onto BOTH the family
2489            // variant and the metadata by `with_beta_phi_frozen_for_search` —
2490            // but `estimated: false` so the inner per-solve Pearson refresh is
2491            // gated off and `F(ρ) = REML(ρ, φ_frozen)` is stationary in ρ.
2492            (ResponseFamily::Beta { phi }, Metadata::FixedBetaPhi { phi: metadata_phi }) => {
2493                if phi.to_bits() != metadata_phi.to_bits() {
2494                    return Err(InvalidLikelihoodScale::new(format!(
2495                        "Beta family precision {phi:?} disagrees with frozen metadata precision {metadata_phi:?}"
2496                    )));
2497                }
2498                Ok(Resolved::BetaPrecision {
2499                    precision: positive(*phi, "Beta precision")?,
2500                    estimated: false,
2501                })
2502            }
2503            (ResponseFamily::Beta { .. }, _) => {
2504                Err(mismatch("matching EstimatedBetaPhi or FixedBetaPhi metadata"))
2505            }
2506
2507            (
2508                ResponseFamily::NegativeBinomial {
2509                    theta,
2510                    theta_fixed: false,
2511                },
2512                Metadata::EstimatedNegBinTheta {
2513                    theta: metadata_theta,
2514                },
2515            )
2516            | (
2517                ResponseFamily::NegativeBinomial {
2518                    theta,
2519                    theta_fixed: true,
2520                },
2521                Metadata::FixedNegBinTheta {
2522                    theta: metadata_theta,
2523                },
2524            ) => {
2525                if theta.to_bits() != metadata_theta.to_bits() {
2526                    return Err(InvalidLikelihoodScale::new(format!(
2527                        "negative-binomial family theta {theta:?} disagrees with metadata theta {metadata_theta:?}"
2528                    )));
2529                }
2530                Ok(Resolved::NegativeBinomial {
2531                    theta: positive(*theta, "negative-binomial theta")?,
2532                    estimated: !matches!(self.scale, Metadata::FixedNegBinTheta { .. }),
2533                })
2534            }
2535            (ResponseFamily::NegativeBinomial { .. }, _) => Err(mismatch(
2536                "matching EstimatedNegBinTheta/FixedNegBinTheta metadata and ownership flag",
2537            )),
2538
2539            (ResponseFamily::RoystonParmar, Metadata::Unspecified) => Ok(Resolved::Unspecified),
2540            (ResponseFamily::RoystonParmar, _) => {
2541                Err(mismatch("Unspecified metadata (no scalar GLM scale)"))
2542            }
2543        }
2544    }
2545
2546    #[inline]
2547    pub fn resolved_gamma_shape(&self) -> Result<f64, InvalidLikelihoodScale> {
2548        self.resolved_scale()?.gamma_shape()
2549    }
2550
2551    #[inline]
2552    pub fn resolved_gamma_log_shape(&self) -> Result<f64, InvalidLikelihoodScale> {
2553        self.resolved_scale()?.gamma_log_shape()
2554    }
2555
2556    #[inline]
2557    pub fn resolved_gamma_phi(&self) -> Result<f64, InvalidLikelihoodScale> {
2558        self.resolved_scale()?.gamma_phi()
2559    }
2560
2561    #[inline]
2562    pub fn resolved_tweedie_phi(&self) -> Result<f64, InvalidLikelihoodScale> {
2563        self.resolved_scale()?.tweedie_phi()
2564    }
2565
2566    #[inline]
2567    pub fn resolved_tweedie_log_phi(&self) -> Result<f64, InvalidLikelihoodScale> {
2568        self.resolved_scale()?.tweedie_log_phi()
2569    }
2570
2571    #[inline]
2572    pub fn resolved_negbin_theta(&self) -> Result<f64, InvalidLikelihoodScale> {
2573        self.resolved_scale()?.negative_binomial_theta()
2574    }
2575
2576    #[inline]
2577    pub fn resolved_beta_precision(&self) -> Result<f64, InvalidLikelihoodScale> {
2578        self.resolved_scale()?.beta_precision()
2579    }
2580
2581    #[inline]
2582    pub fn resolved_beta_log_precision(&self) -> Result<f64, InvalidLikelihoodScale> {
2583        self.resolved_scale()?.beta_log_precision()
2584    }
2585
2586    #[inline]
2587    pub fn resolved_gaussian_log_phi(&self) -> Result<f64, InvalidLikelihoodScale> {
2588        self.resolved_scale()?.gaussian_log_phi()
2589    }
2590
2591    #[inline]
2592    pub fn resolved_gaussian_phi(&self) -> Result<f64, InvalidLikelihoodScale> {
2593        self.resolved_scale()?.gaussian_phi()
2594    }
2595
2596    #[inline]
2597    pub fn link_function(&self) -> LinkFunction {
2598        self.spec.link_function()
2599    }
2600
2601    #[inline]
2602    pub fn fixed_phi(&self) -> Option<f64> {
2603        self.scale.fixed_phi()
2604    }
2605
2606    /// Multiplier converting the stored unscaled inverse penalized Hessian
2607    /// `H⁻¹` into the reported coefficient covariance `Vb = H⁻¹ · scale`.
2608    ///
2609    /// # Invariant
2610    ///
2611    /// `Vb` is the inverse of the Hessian of the *actual penalized objective the
2612    /// inner solver minimizes*. The stored Hessian is always assembled as
2613    /// `H = XᵀWX + S_λ`, with the penalty `S_λ` added **unscaled** (see
2614    /// `pirls::penalty::add_to_hessian`). Whether `H` is already that true
2615    /// objective Hessian — and hence whether any post-hoc dispersion multiply is
2616    /// warranted — is decided entirely by what the IRLS working weight `W`
2617    /// carries:
2618    ///
2619    /// * **Working weight already carries the reciprocal dispersion / full
2620    ///   Fisher information.** Then `H = Xᵀ(W_sf/φ)X + S_λ` already equals the
2621    ///   true penalized Hessian (e.g. mgcv's `XᵀW_sfX/φ + S_λ` for Gamma), so
2622    ///   `Vb = H⁻¹` and the scale is exactly `1.0`. This is the case for Gamma
2623    ///   (`W = prior·shape = prior/φ`), Tweedie (`W = prior·μ^{2−p}/φ`), Beta
2624    ///   and Negative-Binomial (the working weight is the complete fixed-scale
2625    ///   Fisher information), and the fixed-scale exponential families
2626    ///   Poisson/Binomial (`φ ≡ 1`). Multiplying `H⁻¹` by the dispersion again
2627    ///   for any of these double-counts it and shrinks every SE by `√dispersion`.
2628    ///
2629    /// * **Working weight is scale-free** (`W = priorweights`, the profiled
2630    ///   Gaussian convention). Then the data term carries an implicit unit scale
2631    ///   and `H = XᵀPX + S_λ` is the Hessian of `½·(scaled deviance)·σ²⁻¹`
2632    ///   *without* the `σ²`. The correct covariance restores it:
2633    ///   `Vb = H⁻¹ · σ̂²`. Only this branch returns a non-unit scale.
2634    ///
2635    /// `profiled_gaussian_phi` is the profiled residual variance `σ̂²` and is
2636    /// consulted **only** for the scale-free profiled-Gaussian branch; every
2637    /// other family ignores it. This deliberately does NOT touch
2638    /// `dispersion()` / `dispersion_from_likelihood`, which still report the
2639    /// response-level observation noise (`1/shape` for Gamma, `1/(1+φ)` for
2640    /// Beta, …) used by predictive-interval construction — a distinct quantity
2641    /// from the coefficient-covariance scale defined here.
2642    #[inline]
2643    pub fn coefficient_covariance_scale(
2644        &self,
2645        profiled_gaussian_phi: f64,
2646    ) -> Result<f64, InvalidLikelihoodScale> {
2647        match self.resolved_scale()? {
2648            // Scale-free working weight: restore the profiled variance.
2649            ResolvedLikelihoodScale::ProfiledGaussian => {
2650                if profiled_gaussian_phi.is_finite() && profiled_gaussian_phi >= 0.0 {
2651                    Ok(profiled_gaussian_phi)
2652                } else {
2653                    Err(InvalidLikelihoodScale::new(format!(
2654                        "profiled Gaussian covariance scale must be finite and non-negative, got {profiled_gaussian_phi:?}"
2655                    )))
2656                }
2657            }
2658            // Working weight already carries the dispersion / full Fisher
2659            // information, so the stored H is the true penalized Hessian and no
2660            // further dispersion multiply is warranted.
2661            //
2662            // FixedDispersion covers the explicitly-scaled Gaussian submodel
2663            // (W·=1/φ above) and Negative-Binomial; the Gamma, Beta and Tweedie
2664            // variants fold their reciprocal-dispersion / precision / φ into W
2665            // (Tweedie W = prior·μ^{2−p}/φ, so the SE already scales as √φ); and
2666            // Unspecified families never expose a separate post-hoc scale.
2667            ResolvedLikelihoodScale::FixedGaussian { .. }
2668            | ResolvedLikelihoodScale::Unit
2669            | ResolvedLikelihoodScale::Gamma { .. }
2670            | ResolvedLikelihoodScale::BetaPrecision { .. }
2671            | ResolvedLikelihoodScale::Tweedie { .. }
2672            // Negative-Binomial folds `theta` into the working weight
2673            // `W = μθ/(θ+μ)` (the full NB2 Fisher information), so the stored
2674            // `H = XᵀWX + S_λ` is already the true penalized Hessian and the
2675            // covariance scale is `1.0` (`phi ≡ 1`). The reported SEs respond to
2676            // the data's overdispersion entirely through that `theta`-dependent
2677            // weight (issue #802) — multiplying again would double-count it.
2678            // The same holds verbatim for a user-fixed `theta` (issue #983).
2679            | ResolvedLikelihoodScale::NegativeBinomial { .. } => Ok(1.0),
2680            ResolvedLikelihoodScale::Unspecified => Err(InvalidLikelihoodScale::new(
2681                "family has no scalar coefficient-covariance scale".to_string(),
2682            )),
2683        }
2684    }
2685
2686    #[inline]
2687    pub fn gamma_shape(&self) -> Option<f64> {
2688        self.scale.gamma_shape()
2689    }
2690
2691    /// Mutate the Gamma shape parameter in place while preserving the rest of
2692    /// the spec. The shape only takes effect for Gamma families; for other
2693    /// families the scale metadata is left untouched.
2694    #[inline]
2695    pub fn with_gamma_shape(mut self, shape: f64) -> Self {
2696        self.scale = match self.scale {
2697            LikelihoodScaleMetadata::FixedGammaShape { .. } => {
2698                LikelihoodScaleMetadata::FixedGammaShape { shape }
2699            }
2700            LikelihoodScaleMetadata::EstimatedGammaShape { .. } => {
2701                LikelihoodScaleMetadata::EstimatedGammaShape { shape }
2702            }
2703            other => match &self.spec.response {
2704                ResponseFamily::Gamma => LikelihoodScaleMetadata::EstimatedGammaShape { shape },
2705                _ => other,
2706            },
2707        };
2708        self
2709    }
2710
2711    /// Whether the Beta-regression precision `phi` is estimated from data.
2712    #[inline]
2713    pub fn beta_phi_is_estimated(&self) -> bool {
2714        self.scale.beta_phi_is_estimated()
2715    }
2716
2717    /// Mutate the Beta precision `phi` in place, on BOTH the family variant
2718    /// (where every PIRLS weight / deviance / log-likelihood expression reads it
2719    /// via `ResponseFamily::Beta { phi }`) and the scale metadata (the
2720    /// estimated-vs-fixed contract). No-op for non-Beta families. The inner
2721    /// solver calls this once per inner solve after a moment estimate of `phi`
2722    /// from the working residuals, so the IRLS weights `Var(y)=mu(1-mu)/(1+phi)`
2723    /// reflect the true precision rather than the `phi=1` seed (issue #567).
2724    #[inline]
2725    pub fn with_beta_phi(mut self, phi: f64) -> Self {
2726        if let ResponseFamily::Beta { phi: family_phi } = &mut self.spec.response {
2727            *family_phi = phi;
2728            self.scale = LikelihoodScaleMetadata::EstimatedBetaPhi { phi };
2729        }
2730        self
2731    }
2732
2733    /// Whether the Tweedie exponential-dispersion `phi` is estimated from data.
2734    #[inline]
2735    pub fn tweedie_phi_is_estimated(&self) -> bool {
2736        self.scale.tweedie_phi_is_estimated()
2737    }
2738
2739    /// Mutate the Tweedie dispersion `phi` in place. Unlike Beta, the Tweedie
2740    /// power `p` (not `phi`) is what is carried on the `ResponseFamily::Tweedie`
2741    /// variant; the dispersion lives purely in the scale metadata and is read by
2742    /// the IRLS weight (`prior·μ^{2−p}/phi`) through `fixed_phi()`. So updating
2743    /// the metadata here is sufficient to thread the estimated `phi` into every
2744    /// weight / covariance expression. No-op for non-Tweedie families (issue
2745    /// #771).
2746    #[inline]
2747    pub fn with_tweedie_phi(mut self, phi: f64) -> Self {
2748        if matches!(self.spec.response, ResponseFamily::Tweedie { .. }) {
2749            self.scale = LikelihoodScaleMetadata::EstimatedTweediePhi { phi };
2750        }
2751        self
2752    }
2753
2754    /// Whether the Negative-Binomial overdispersion `theta` is estimated from
2755    /// data (issue #802).
2756    #[inline]
2757    pub fn negbin_theta_is_estimated(&self) -> bool {
2758        self.scale.negbin_theta_is_estimated()
2759    }
2760
2761    /// Mutate the Negative-Binomial overdispersion `theta` in place, on BOTH the
2762    /// family variant (where every PIRLS weight / deviance / log-likelihood
2763    /// expression reads it via `ResponseFamily::NegativeBinomial { theta }`) and
2764    /// the scale metadata (the estimated-vs-fixed contract). No-op for non-NB
2765    /// families. The inner solver calls this once per inner solve after a
2766    /// maximum-likelihood estimate of `theta` from the working residuals, so the
2767    /// IRLS weight `W = μθ/(θ+μ)` and the variance `Var(y)=mu+mu^2/theta` reflect
2768    /// the data's overdispersion rather than the seed `theta` (issue #802). This
2769    /// mirrors `with_beta_phi` exactly — both keep the family variant and the
2770    /// scale metadata as two synchronized views of one estimated parameter.
2771    /// No-op for a user-fixed `theta` (`theta_fixed = true` /
2772    /// `FixedNegBinTheta`, issue #983): the held value is the contract, and
2773    /// this mutator must never let an estimation path overwrite it — the
2774    /// PIRLS refresh gate (`negbin_theta_is_estimated()`) already skips the
2775    /// call, this enforces the same invariant at the data itself.
2776    #[inline]
2777    pub fn with_negbin_theta(mut self, theta: f64) -> Self {
2778        if let ResponseFamily::NegativeBinomial {
2779            theta: family_theta,
2780            theta_fixed,
2781        } = &mut self.spec.response
2782            && !*theta_fixed
2783        {
2784            *family_theta = theta;
2785            self.scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta };
2786        }
2787        self
2788    }
2789
2790    /// The estimated Negative-Binomial `theta`, read from the family variant
2791    /// (the canonical store), or `None` for non-NB families.
2792    #[inline]
2793    pub fn negbin_theta(&self) -> Option<f64> {
2794        match self.spec.response {
2795            ResponseFamily::NegativeBinomial { theta, .. } => Some(theta),
2796            _ => None,
2797        }
2798    }
2799
2800    /// Produce a copy of this spec with the Tweedie exponential-dispersion
2801    /// `phi` PINNED at `phi` for the duration of the smoothing-parameter (λ)
2802    /// search (#1477). Converts an `EstimatedTweediePhi` scale into the
2803    /// statistically-identical `FixedDispersion` form, which gates off the
2804    /// per-inner-solve Pearson refresh in
2805    /// `GamWorkingModel::update_with_curvature` (its guard is
2806    /// `tweedie_phi_is_estimated()`, which `FixedDispersion` does not satisfy)
2807    /// while leaving every weight / variance / covariance expression unchanged
2808    /// (they read `phi` through `fixed_phi()`, which `FixedDispersion` answers
2809    /// identically).
2810    ///
2811    /// Rationale: with `phi` estimated, the inner solver re-derives it from each
2812    /// outer iterate's *warm-start* η (the Pearson moment estimator
2813    /// `phî = Σ wᵢ(yᵢ−μᵢ)²/μᵢ^p / Σ wᵢ`). The Tweedie LAML omits the
2814    /// `phi`-dependent saddlepoint normalizer `a(y,φ)` from `−ℓ(β̂)` — valid only
2815    /// when `phi` is fixed across the surface — so a drifting `phi` makes
2816    /// `F(ρ)` a non-stationary function of ρ that REWARDS dispersion inflation:
2817    /// driving a double-penalty null-space `λ` up kills a genuinely-supported
2818    /// linear trend, the residuals grow, the warm-start `phî` rises, and the
2819    /// `[yθ−κ]/φ` deviance term shrinks with no compensating normalizer penalty,
2820    /// so the criterion falls and the outer optimizer rails `λ_null` to the box
2821    /// bound (the #1477 Tweedie double-penalty boundary blow-up). Holding `phi`
2822    /// fixed across the λ-search makes `F(ρ) = REML(ρ, φ_frozen)` a genuine
2823    /// stationary function of ρ, exactly as for the Gaussian profiled scale
2824    /// (whose `(n−Mp)/2·log(2πφ̂)` normalizer is retained) and as mgcv does for
2825    /// Tweedie. `phi` is still Pearson-refreshed at the single final reported fit
2826    /// (the `refine_dispersion_at_converged_eta = true` accept-fit). No-op for
2827    /// non-Tweedie families and for a user-fixed `phi`.
2828    #[inline]
2829    pub fn with_tweedie_phi_frozen_for_search(mut self, phi: f64) -> Self {
2830        if matches!(self.spec.response, ResponseFamily::Tweedie { .. })
2831            && self.scale.tweedie_phi_is_estimated()
2832        {
2833            self.scale = LikelihoodScaleMetadata::FixedDispersion { phi };
2834        }
2835        self
2836    }
2837
2838    /// Produce a copy of this spec with the Negative-Binomial overdispersion
2839    /// `theta` PINNED at `theta` for the duration of the smoothing-parameter
2840    /// (λ) search (#1082). Converts an `EstimatedNegBinTheta` spec into the
2841    /// statistically-identical `FixedNegBinTheta` form (`theta_fixed = true`),
2842    /// which gates off the per-inner-solve ML refresh in
2843    /// `GamWorkingModel::update_with_curvature` (its guard is
2844    /// `negbin_theta_is_estimated()`).
2845    ///
2846    /// Rationale: with θ estimated, the inner solver re-derives θ from each
2847    /// outer iterate's *warm-start* η, so θ — and hence the NB working response,
2848    /// deviance and penalty-logdet that feed the REML criterion — drifts every
2849    /// outer evaluation. The outer optimizer then chases a moving target and the
2850    /// projected-gradient convergence test never trips, grinding the loop to
2851    /// `max_iter` (the #1082 negative-binomial tensor timeout). Holding θ fixed
2852    /// across the λ-search makes the REML objective `F(ρ) = REML(ρ, θ_frozen)` a
2853    /// genuine stationary function of ρ, so the loop converges in a handful of
2854    /// iterations — and θ is still ML-refreshed at the single final, reported fit
2855    /// (the `refine_dispersion_at_converged_eta = true` accept-fit), exactly as
2856    /// the function-level docs require ("estimate the scale at the converged fit,
2857    /// not inside the λ search; mgcv likewise"). No-op for non-NB families and
2858    /// for an already user-fixed θ.
2859    #[inline]
2860    pub fn with_negbin_theta_frozen_for_search(mut self, theta: f64) -> Self {
2861        if let ResponseFamily::NegativeBinomial {
2862            theta: family_theta,
2863            theta_fixed,
2864        } = &mut self.spec.response
2865        {
2866            *family_theta = theta;
2867            *theta_fixed = true;
2868            self.scale = LikelihoodScaleMetadata::FixedNegBinTheta { theta };
2869        }
2870        self
2871    }
2872
2873    /// Produce a copy of this spec with the Gamma shape `k = 1/φ` PINNED at
2874    /// `shape` for the duration of the smoothing-parameter (λ) search (#1074).
2875    /// Converts an `EstimatedGammaShape` scale into the statistically-identical
2876    /// `FixedGammaShape` form, which gates off the per-inner-solve shape refresh
2877    /// in `GamWorkingModel::update_with_curvature` (its guard is
2878    /// `gamma_shape_is_estimated()`, which `FixedGammaShape` does not satisfy)
2879    /// while leaving every weight / deviance / log-likelihood expression
2880    /// unchanged (they read the shape through `gamma_shape()` / `fixed_phi()`,
2881    /// which `FixedGammaShape` answers identically).
2882    ///
2883    /// Rationale: with the shape estimated, the inner solver re-derives it from
2884    /// each outer iterate's *warm-start* η (the converged-η MLE
2885    /// `k̂` solving `ln k − ψ(k) = mean[y/μ − ln(y/μ) − 1]`). The Gamma working
2886    /// weight is `W = prior·k` and the omitting-constants log-likelihood is
2887    /// `ℓ(β̂) = −k·½·D(ρ)` (the `k`-dependent saturated normalizer is dropped,
2888    /// #359), so a `k` that swings 2×↔ with the warm-start η makes BOTH the
2889    /// likelihood-curvature `H = k·XᵀX + λS` and the data-fit term `k·½D` jump
2890    /// discontinuously with ρ — the REML criterion `V(ρ)` develops deterministic
2891    /// spikes between the smooth basin floors (e.g. a flat warm-start η at a
2892    /// just-rejected over-smoothed trial gives `k≈2.3`, the fitted-surface η at
2893    /// the neighbor gives `k≈4.7`, doubling `−ℓ` with β̂ essentially unchanged).
2894    /// The analytic outer gradient holds `k` fixed, so it can never agree with
2895    /// the realized cost's `k(ρ)` motion: the projected gradient floors at
2896    /// `O(|∂k/∂ρ|·½D)` and the ARC descent stalls on a weakly-identified valley,
2897    /// railing `λ` to the over-smoothed corner (the #1074 te/Gamma tensor
2898    /// under-recovery). Holding `k` fixed across the λ-search makes
2899    /// `F(ρ) = REML(ρ, k_frozen)` a genuine stationary function of ρ, exactly as
2900    /// the sibling Tweedie-φ (#1477) and NB-θ (#1082) freezes do, and as mgcv
2901    /// does (it fixes the scale across the smoothness search for the scale-free
2902    /// Gamma mean). `k` is still ML-refreshed at the single final reported fit
2903    /// (the `refine_dispersion_at_converged_eta = true` accept-fit), so the
2904    /// reported dispersion / SEs remain the converged-η estimate. No-op for
2905    /// non-Gamma families and for a user-fixed shape.
2906    #[inline]
2907    pub fn with_gamma_shape_frozen_for_search(mut self, shape: f64) -> Self {
2908        if matches!(self.spec.response, ResponseFamily::Gamma)
2909            && self.scale.gamma_shape_is_estimated()
2910        {
2911            self.scale = LikelihoodScaleMetadata::FixedGammaShape { shape };
2912        }
2913        self
2914    }
2915
2916    /// Produce a copy of this spec with the Beta-regression precision `phi`
2917    /// PINNED at `phi` for the duration of the smoothing-parameter (λ) search
2918    /// (#2369). Converts an `EstimatedBetaPhi` scale into the
2919    /// statistically-identical `FixedBetaPhi` form, which gates off the
2920    /// per-inner-solve Pearson refresh in
2921    /// `GamWorkingModel::update_with_curvature` (its guard is
2922    /// `beta_phi_is_estimated()`, which `FixedBetaPhi` does not satisfy) while
2923    /// leaving every weight / deviance / mean-score / log-likelihood expression
2924    /// unchanged (they read `phi` through the `Beta { phi }` family variant and
2925    /// `fixed_phi()`, which `FixedBetaPhi` answers identically). Both the family
2926    /// variant and the metadata are set to the frozen value so the
2927    /// `resolved_scale` mirror invariant holds.
2928    ///
2929    /// Rationale: with `phi` estimated, the inner solver re-derives it from each
2930    /// outer iterate's *warm-start* η via the Pearson moment estimator
2931    /// `1+phî = Σw / Σ w·(y−μ)²/(μ(1−μ))`. Unlike a canonical-link GLM the Beta
2932    /// precision does NOT factor out of the mean: the β score is
2933    /// `∂ℓ/∂β = phi·Σ xᵢ(y*ᵢ − μ*ᵢ)` with `μ*ᵢ = ψ(μᵢφ) − ψ((1−μᵢ)φ)`, so a
2934    /// `phi` that swings with the warm-start η makes BOTH the mean fit β̂(ρ) and
2935    /// the REML data-fit / log-det terms jump with ρ. The analytic outer
2936    /// gradient holds `phi` fixed, so it can never agree with the realized
2937    /// cost's `phi(ρ)` motion: the projected gradient floors above tolerance and
2938    /// the outer optimizer refuses ("NOT STATIONARY"), exactly as the sibling
2939    /// Gamma-shape (#1074), Tweedie-φ (#1477) and NB-θ (#1082) freezes document.
2940    /// Holding `phi` fixed across the λ-search makes `F(ρ) = REML(ρ, φ_frozen)`
2941    /// a genuine stationary function of ρ. `phi` is still Pearson-refreshed at
2942    /// the single final reported fit (the
2943    /// `refine_dispersion_at_converged_eta = true` accept-fit), so the reported
2944    /// precision / SEs remain the converged-η estimate. No-op for non-Beta
2945    /// families and for an already-fixed `phi`.
2946    #[inline]
2947    pub fn with_beta_phi_frozen_for_search(mut self, phi: f64) -> Self {
2948        if let ResponseFamily::Beta { phi: family_phi } = &mut self.spec.response
2949            && self.scale.beta_phi_is_estimated()
2950        {
2951            *family_phi = phi;
2952            self.scale = LikelihoodScaleMetadata::FixedBetaPhi { phi };
2953        }
2954        self
2955    }
2956}
2957
2958#[cfg(test)]
2959mod tests {
2960    use super::*;
2961    use ndarray::arr1;
2962
2963    #[test]
2964    fn resolved_likelihood_scale_rejects_missing_and_mismatched_ownership() {
2965        let beta_mismatch = GlmLikelihoodSpec {
2966            spec: LikelihoodSpec::beta_logit(3.0),
2967            scale: LikelihoodScaleMetadata::EstimatedBetaPhi {
2968                phi: f64::from_bits(3.0_f64.to_bits() + 1),
2969            },
2970        };
2971        assert!(
2972            beta_mismatch
2973                .resolved_scale()
2974                .expect_err("Beta mirrored precision mismatch must fail")
2975                .to_string()
2976                .contains("disagrees")
2977        );
2978
2979        let nb_owner_mismatch = GlmLikelihoodSpec {
2980            spec: LikelihoodSpec::negative_binomial_log(2.0),
2981            scale: LikelihoodScaleMetadata::FixedNegBinTheta { theta: 2.0 },
2982        };
2983        assert!(
2984            nb_owner_mismatch
2985                .resolved_scale()
2986                .expect_err("NB fixed/estimated ownership mismatch must fail")
2987                .to_string()
2988                .contains("ownership flag")
2989        );
2990
2991        let gamma_missing = GlmLikelihoodSpec {
2992            spec: LikelihoodSpec::gamma_log(),
2993            scale: LikelihoodScaleMetadata::Unspecified,
2994        };
2995        assert!(
2996            gamma_missing
2997                .resolved_scale()
2998                .expect_err("Gamma cannot fabricate a unit shape")
2999                .to_string()
3000                .contains("GammaShape")
3001        );
3002
3003        let poisson_nonunit = GlmLikelihoodSpec {
3004            spec: LikelihoodSpec::poisson_log(),
3005            scale: LikelihoodScaleMetadata::FixedDispersion { phi: 2.0 },
3006        };
3007        assert!(
3008            poisson_nonunit
3009                .resolved_scale()
3010                .expect_err("Poisson scale must be exact unit")
3011                .to_string()
3012                .contains("phi: 1.0")
3013        );
3014    }
3015
3016    #[test]
3017    fn glm_likelihood_deserialization_validates_family_and_scale_atomically() {
3018        let invalid = GlmLikelihoodSpec {
3019            spec: LikelihoodSpec::beta_logit(3.0),
3020            scale: LikelihoodScaleMetadata::EstimatedBetaPhi { phi: 4.0 },
3021        };
3022        let encoded = serde_json::to_string(&invalid).expect("serialize test payload");
3023        let error = serde_json::from_str::<GlmLikelihoodSpec>(&encoded)
3024            .expect_err("contradictory family/metadata bytes must be rejected");
3025        assert!(error.to_string().contains("disagrees"));
3026
3027        let valid = GlmLikelihoodSpec::try_new(
3028            LikelihoodSpec::negative_binomial_log(2.0),
3029            LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: 2.0 },
3030        )
3031        .expect("valid likelihood");
3032        let encoded = serde_json::to_string(&valid).expect("serialize valid likelihood");
3033        let decoded: GlmLikelihoodSpec =
3034            serde_json::from_str(&encoded).expect("deserialize valid likelihood");
3035        assert_eq!(decoded, valid);
3036    }
3037
3038    #[test]
3039    fn resolved_likelihood_scale_preserves_extremes_in_log_coordinates() {
3040        let smallest_subnormal = f64::from_bits(1);
3041        let gamma_from_phi = GlmLikelihoodSpec {
3042            spec: LikelihoodSpec::gamma_log(),
3043            scale: LikelihoodScaleMetadata::FixedDispersion {
3044                phi: smallest_subnormal,
3045            },
3046        };
3047        assert_eq!(
3048            gamma_from_phi
3049                .resolved_gamma_log_shape()
3050                .expect("Gamma log shape remains representable")
3051                .to_bits(),
3052            (-smallest_subnormal.ln()).to_bits()
3053        );
3054        assert!(
3055            gamma_from_phi.resolved_gamma_shape().is_err(),
3056            "raw reciprocal beyond f64 must fail only at the raw-shape consumer"
3057        );
3058
3059        let gamma_from_shape = GlmLikelihoodSpec {
3060            spec: LikelihoodSpec::gamma_log(),
3061            scale: LikelihoodScaleMetadata::FixedGammaShape {
3062                shape: smallest_subnormal,
3063            },
3064        };
3065        assert_eq!(
3066            gamma_from_shape
3067                .resolved_gamma_shape()
3068                .expect("subnormal shape is still a positive shape")
3069                .to_bits(),
3070            smallest_subnormal.to_bits()
3071        );
3072        assert!(gamma_from_shape.resolved_gamma_phi().is_err());
3073
3074        let tweedie = GlmLikelihoodSpec {
3075            spec: LikelihoodSpec::tweedie_log(1.5),
3076            scale: LikelihoodScaleMetadata::FixedDispersion {
3077                phi: smallest_subnormal,
3078            },
3079        };
3080        assert_eq!(
3081            tweedie
3082                .resolved_tweedie_phi()
3083                .expect("positive subnormal Tweedie phi")
3084                .to_bits(),
3085            smallest_subnormal.to_bits()
3086        );
3087        assert!(tweedie.resolved_tweedie_log_phi().unwrap().is_finite());
3088    }
3089
3090    // -----------------------------------------------------------------------
3091    // CoefficientGroupPrior::validate
3092    // -----------------------------------------------------------------------
3093
3094    #[test]
3095    fn prior_flat_always_ok() {
3096        assert!(CoefficientGroupPrior::Flat.validate("ctx").is_ok());
3097    }
3098
3099    #[test]
3100    fn prior_normal_log_precision_valid() {
3101        assert!(
3102            CoefficientGroupPrior::NormalLogPrecision { mean: 0.0, sd: 1.0 }
3103                .validate("ctx")
3104                .is_ok()
3105        );
3106    }
3107
3108    #[test]
3109    fn prior_normal_log_precision_infinite_mean_errors() {
3110        assert!(
3111            CoefficientGroupPrior::NormalLogPrecision {
3112                mean: f64::INFINITY,
3113                sd: 1.0
3114            }
3115            .validate("ctx")
3116            .is_err()
3117        );
3118    }
3119
3120    #[test]
3121    fn prior_normal_log_precision_zero_sd_errors() {
3122        assert!(
3123            CoefficientGroupPrior::NormalLogPrecision { mean: 0.0, sd: 0.0 }
3124                .validate("ctx")
3125                .is_err()
3126        );
3127    }
3128
3129    #[test]
3130    fn prior_normal_log_precision_negative_sd_errors() {
3131        assert!(
3132            CoefficientGroupPrior::NormalLogPrecision {
3133                mean: 0.0,
3134                sd: -1.0
3135            }
3136            .validate("ctx")
3137            .is_err()
3138        );
3139    }
3140
3141    #[test]
3142    fn prior_gamma_precision_valid() {
3143        assert!(
3144            CoefficientGroupPrior::GammaPrecision {
3145                shape: 1.0,
3146                rate: 0.0
3147            }
3148            .validate("ctx")
3149            .is_ok()
3150        );
3151    }
3152
3153    #[test]
3154    fn prior_gamma_precision_zero_shape_errors() {
3155        assert!(
3156            CoefficientGroupPrior::GammaPrecision {
3157                shape: 0.0,
3158                rate: 1.0
3159            }
3160            .validate("ctx")
3161            .is_err()
3162        );
3163    }
3164
3165    #[test]
3166    fn prior_gamma_precision_negative_rate_errors() {
3167        assert!(
3168            CoefficientGroupPrior::GammaPrecision {
3169                shape: 1.0,
3170                rate: -0.1
3171            }
3172            .validate("ctx")
3173            .is_err()
3174        );
3175    }
3176
3177    #[test]
3178    fn prior_penalized_complexity_valid() {
3179        assert!(
3180            CoefficientGroupPrior::PenalizedComplexity {
3181                upper: 1.0,
3182                tail_prob: 0.05
3183            }
3184            .validate("ctx")
3185            .is_ok()
3186        );
3187    }
3188
3189    #[test]
3190    fn prior_penalized_complexity_zero_upper_errors() {
3191        assert!(
3192            CoefficientGroupPrior::PenalizedComplexity {
3193                upper: 0.0,
3194                tail_prob: 0.05
3195            }
3196            .validate("ctx")
3197            .is_err()
3198        );
3199    }
3200
3201    #[test]
3202    fn prior_penalized_complexity_tail_prob_zero_errors() {
3203        assert!(
3204            CoefficientGroupPrior::PenalizedComplexity {
3205                upper: 1.0,
3206                tail_prob: 0.0
3207            }
3208            .validate("ctx")
3209            .is_err()
3210        );
3211    }
3212
3213    #[test]
3214    fn prior_penalized_complexity_tail_prob_one_errors() {
3215        assert!(
3216            CoefficientGroupPrior::PenalizedComplexity {
3217                upper: 1.0,
3218                tail_prob: 1.0
3219            }
3220            .validate("ctx")
3221            .is_err()
3222        );
3223    }
3224
3225    // -----------------------------------------------------------------------
3226    // LatentCLogLogState::new
3227    // -----------------------------------------------------------------------
3228
3229    #[test]
3230    fn latent_cloglog_zero_sd_ok() {
3231        assert!(LatentCLogLogState::new(0.0).is_ok());
3232    }
3233
3234    #[test]
3235    fn latent_cloglog_positive_sd_ok() {
3236        assert!(LatentCLogLogState::new(1.5).is_ok());
3237    }
3238
3239    #[test]
3240    fn latent_cloglog_negative_sd_errors() {
3241        assert!(LatentCLogLogState::new(-0.1).is_err());
3242    }
3243
3244    #[test]
3245    fn latent_cloglog_infinite_sd_errors() {
3246        assert!(LatentCLogLogState::new(f64::INFINITY).is_err());
3247    }
3248
3249    #[test]
3250    fn latent_cloglog_nan_errors() {
3251        assert!(LatentCLogLogState::new(f64::NAN).is_err());
3252    }
3253
3254    // -----------------------------------------------------------------------
3255    // WigglePenaltyConfig::cubic_triple_operator_default
3256    // -----------------------------------------------------------------------
3257
3258    #[test]
3259    fn wiggle_penalty_default_fields() {
3260        let cfg = WigglePenaltyConfig::cubic_triple_operator_default();
3261        assert_eq!(cfg.degree, 3);
3262        assert_eq!(cfg.num_internal_knots, 8);
3263        assert_eq!(cfg.penalty_orders, vec![1, 2, 3]);
3264        assert!(cfg.double_penalty);
3265        assert!((cfg.monotonicity_eps - 1e-4).abs() < 1e-15);
3266    }
3267
3268    // -----------------------------------------------------------------------
3269    // is_valid_tweedie_power
3270    // -----------------------------------------------------------------------
3271
3272    #[test]
3273    fn tweedie_power_valid_interior() {
3274        assert!(is_valid_tweedie_power(1.5));
3275        assert!(is_valid_tweedie_power(1.1));
3276        assert!(is_valid_tweedie_power(1.9));
3277    }
3278
3279    #[test]
3280    fn tweedie_power_boundaries_invalid() {
3281        assert!(!is_valid_tweedie_power(1.0));
3282        assert!(!is_valid_tweedie_power(2.0));
3283    }
3284
3285    #[test]
3286    fn tweedie_power_outside_interval_invalid() {
3287        assert!(!is_valid_tweedie_power(0.5));
3288        assert!(!is_valid_tweedie_power(2.5));
3289        assert!(!is_valid_tweedie_power(-1.0));
3290        assert!(!is_valid_tweedie_power(f64::INFINITY));
3291    }
3292
3293    // -----------------------------------------------------------------------
3294    // StandardLink <-> LinkFunction conversions
3295    // -----------------------------------------------------------------------
3296
3297    #[test]
3298    fn standard_link_roundtrip_to_link_function() {
3299        assert_eq!(StandardLink::Logit.as_link_function(), LinkFunction::Logit);
3300        assert_eq!(
3301            StandardLink::Probit.as_link_function(),
3302            LinkFunction::Probit
3303        );
3304        assert_eq!(
3305            StandardLink::CLogLog.as_link_function(),
3306            LinkFunction::CLogLog
3307        );
3308        assert_eq!(
3309            StandardLink::Identity.as_link_function(),
3310            LinkFunction::Identity
3311        );
3312        assert_eq!(StandardLink::Log.as_link_function(), LinkFunction::Log);
3313    }
3314
3315    #[test]
3316    fn standard_link_from_link_function_state_bearing_errors() {
3317        assert!(StandardLink::try_from(LinkFunction::Sas).is_err());
3318        assert!(StandardLink::try_from(LinkFunction::BetaLogistic).is_err());
3319    }
3320
3321    #[test]
3322    fn standard_link_from_link_function_standard_ok() {
3323        assert_eq!(
3324            StandardLink::try_from(LinkFunction::Logit),
3325            Ok(StandardLink::Logit)
3326        );
3327        assert_eq!(
3328            StandardLink::try_from(LinkFunction::Log),
3329            Ok(StandardLink::Log)
3330        );
3331    }
3332
3333    // -----------------------------------------------------------------------
3334    // LikelihoodSpec: legal-cell matrix
3335    // -----------------------------------------------------------------------
3336
3337    #[test]
3338    fn legal_cells_accepted() {
3339        assert!(
3340            LikelihoodSpec::try_new(
3341                ResponseFamily::Gaussian,
3342                InverseLink::Standard(StandardLink::Identity)
3343            )
3344            .is_ok()
3345        );
3346        assert!(
3347            LikelihoodSpec::try_new(
3348                ResponseFamily::Poisson,
3349                InverseLink::Standard(StandardLink::Log)
3350            )
3351            .is_ok()
3352        );
3353        assert!(
3354            LikelihoodSpec::try_new(
3355                ResponseFamily::Gamma,
3356                InverseLink::Standard(StandardLink::Log)
3357            )
3358            .is_ok()
3359        );
3360        assert!(
3361            LikelihoodSpec::try_new(
3362                ResponseFamily::Beta { phi: 1.0 },
3363                InverseLink::Standard(StandardLink::Logit)
3364            )
3365            .is_ok()
3366        );
3367        assert!(
3368            LikelihoodSpec::try_new(
3369                ResponseFamily::Binomial,
3370                InverseLink::Standard(StandardLink::Logit)
3371            )
3372            .is_ok()
3373        );
3374        assert!(
3375            LikelihoodSpec::try_new(
3376                ResponseFamily::Binomial,
3377                InverseLink::Standard(StandardLink::Probit)
3378            )
3379            .is_ok()
3380        );
3381        assert!(
3382            LikelihoodSpec::try_new(
3383                ResponseFamily::Binomial,
3384                InverseLink::Standard(StandardLink::CLogLog)
3385            )
3386            .is_ok()
3387        );
3388    }
3389
3390    #[test]
3391    fn illegal_cells_rejected() {
3392        assert!(
3393            LikelihoodSpec::try_new(
3394                ResponseFamily::Poisson,
3395                InverseLink::Standard(StandardLink::Identity)
3396            )
3397            .is_err()
3398        );
3399        assert!(
3400            LikelihoodSpec::try_new(
3401                ResponseFamily::Gaussian,
3402                InverseLink::Standard(StandardLink::Logit)
3403            )
3404            .is_err()
3405        );
3406        assert!(
3407            LikelihoodSpec::try_new(
3408                ResponseFamily::Binomial,
3409                InverseLink::Standard(StandardLink::Log)
3410            )
3411            .is_err()
3412        );
3413        assert!(
3414            LikelihoodSpec::try_new(
3415                ResponseFamily::Binomial,
3416                InverseLink::Standard(StandardLink::Identity)
3417            )
3418            .is_err()
3419        );
3420    }
3421
3422    #[test]
3423    fn likelihood_spec_kind_names() {
3424        assert_eq!(LikelihoodSpec::gaussian_identity().name(), "gaussian");
3425        assert_eq!(LikelihoodSpec::poisson_log().name(), "poisson-log");
3426        assert_eq!(LikelihoodSpec::binomial_logit().name(), "binomial-logit");
3427        assert_eq!(LikelihoodSpec::gamma_log().name(), "gamma-log");
3428    }
3429
3430    // -----------------------------------------------------------------------
3431    // ResponseFamily::infer_from_response
3432    // -----------------------------------------------------------------------
3433
3434    #[test]
3435    fn infer_binary_kind_gives_binomial() {
3436        let y = arr1(&[0.0_f64, 1.0]);
3437        let result = ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Binary);
3438        assert!(matches!(result, Ok(ResponseFamily::Binomial)));
3439    }
3440
3441    #[test]
3442    fn infer_categorical_kind_refuses() {
3443        let y = arr1(&[0.0_f64, 1.0]);
3444        let result = ResponseFamily::infer_from_response(
3445            y.view(),
3446            ResponseColumnKind::Categorical {
3447                levels: vec!["yes".to_string(), "no".to_string()],
3448            },
3449        );
3450        assert!(result.is_err());
3451    }
3452
3453    #[test]
3454    fn infer_numeric_binary_values_gives_binomial() {
3455        let y = arr1(&[0.0_f64, 1.0, 0.0, 1.0, 0.0]);
3456        let result = ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
3457        assert!(matches!(result, Ok(ResponseFamily::Binomial)));
3458    }
3459
3460    #[test]
3461    fn infer_numeric_count_values_gives_poisson() {
3462        let y = arr1(&[0.0_f64, 1.0, 2.0, 3.0, 5.0]);
3463        let result = ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
3464        assert!(matches!(result, Ok(ResponseFamily::Poisson)));
3465    }
3466
3467    #[test]
3468    fn infer_numeric_fractional_gives_gaussian() {
3469        let y = arr1(&[1.5_f64, 2.3, 3.7]);
3470        let result = ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
3471        assert!(matches!(result, Ok(ResponseFamily::Gaussian)));
3472    }
3473
3474    #[test]
3475    fn infer_numeric_negative_gives_gaussian() {
3476        let y = arr1(&[-1.0_f64, 0.0, 1.0]);
3477        let result = ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
3478        assert!(matches!(result, Ok(ResponseFamily::Gaussian)));
3479    }
3480
3481    // -----------------------------------------------------------------------
3482    // ResponseFamily::validate_response_support
3483    // -----------------------------------------------------------------------
3484
3485    #[test]
3486    fn gaussian_support_accepts_any_finite() {
3487        let y = arr1(&[-100.0_f64, 0.0, 100.0]);
3488        assert!(
3489            ResponseFamily::Gaussian
3490                .validate_response_support(y.view())
3491                .is_ok()
3492        );
3493    }
3494
3495    #[test]
3496    fn gamma_support_rejects_zero() {
3497        let y = arr1(&[0.0_f64, 1.0, 2.0]);
3498        assert!(
3499            ResponseFamily::Gamma
3500                .validate_response_support(y.view())
3501                .is_err()
3502        );
3503    }
3504
3505    #[test]
3506    fn gamma_support_rejects_negative() {
3507        let y = arr1(&[-1.0_f64, 1.0]);
3508        assert!(
3509            ResponseFamily::Gamma
3510                .validate_response_support(y.view())
3511                .is_err()
3512        );
3513    }
3514
3515    #[test]
3516    fn gamma_support_accepts_positive() {
3517        let y = arr1(&[0.1_f64, 1.0, 100.0]);
3518        assert!(
3519            ResponseFamily::Gamma
3520                .validate_response_support(y.view())
3521                .is_ok()
3522        );
3523    }
3524
3525    #[test]
3526    fn binomial_support_accepts_fractional_proportions() {
3527        let y = arr1(&[0.0_f64, 0.5, 1.0]);
3528        assert!(
3529            ResponseFamily::Binomial
3530                .validate_response_support(y.view())
3531                .is_ok()
3532        );
3533    }
3534
3535    #[test]
3536    fn binomial_support_rejects_values_outside_unit_interval() {
3537        let y = arr1(&[0.0_f64, -0.1, 1.1]);
3538        assert!(
3539            ResponseFamily::Binomial
3540                .validate_response_support(y.view())
3541                .is_err()
3542        );
3543    }
3544
3545    #[test]
3546    fn binomial_support_accepts_binary() {
3547        let y = arr1(&[0.0_f64, 1.0, 0.0, 1.0]);
3548        assert!(
3549            ResponseFamily::Binomial
3550                .validate_response_support(y.view())
3551                .is_ok()
3552        );
3553    }
3554
3555    #[test]
3556    fn poisson_support_rejects_negative() {
3557        let y = arr1(&[-1.0_f64, 0.0, 1.0]);
3558        assert!(
3559            ResponseFamily::Poisson
3560                .validate_response_support(y.view())
3561                .is_err()
3562        );
3563    }
3564
3565    #[test]
3566    fn poisson_support_accepts_nonneg() {
3567        let y = arr1(&[0.0_f64, 1.0, 2.0, 10.0]);
3568        assert!(
3569            ResponseFamily::Poisson
3570                .validate_response_support(y.view())
3571                .is_ok()
3572        );
3573    }
3574
3575    #[test]
3576    fn beta_support_rejects_zero_boundary() {
3577        let y = arr1(&[0.0_f64, 0.5]);
3578        assert!(
3579            ResponseFamily::Beta { phi: 1.0 }
3580                .validate_response_support(y.view())
3581                .is_err()
3582        );
3583    }
3584
3585    #[test]
3586    fn beta_support_rejects_one_boundary() {
3587        let y = arr1(&[0.5_f64, 1.0]);
3588        assert!(
3589            ResponseFamily::Beta { phi: 1.0 }
3590                .validate_response_support(y.view())
3591                .is_err()
3592        );
3593    }
3594
3595    #[test]
3596    fn beta_support_accepts_open_interval() {
3597        let y = arr1(&[0.1_f64, 0.5, 0.9]);
3598        assert!(
3599            ResponseFamily::Beta { phi: 1.0 }
3600                .validate_response_support(y.view())
3601                .is_ok()
3602        );
3603    }
3604
3605    // -----------------------------------------------------------------------
3606    // ResponseFamily::validate_response_degeneracy
3607    // -----------------------------------------------------------------------
3608
3609    #[test]
3610    fn binomial_degeneracy_all_zeros_errors() {
3611        let y = arr1(&[0.0_f64, 0.0, 0.0]);
3612        assert!(
3613            ResponseFamily::Binomial
3614                .validate_response_degeneracy(y.view())
3615                .is_err()
3616        );
3617    }
3618
3619    #[test]
3620    fn binomial_degeneracy_all_ones_errors() {
3621        let y = arr1(&[1.0_f64, 1.0, 1.0]);
3622        assert!(
3623            ResponseFamily::Binomial
3624                .validate_response_degeneracy(y.view())
3625                .is_err()
3626        );
3627    }
3628
3629    #[test]
3630    fn binomial_degeneracy_mixed_ok() {
3631        let y = arr1(&[0.0_f64, 1.0, 0.0]);
3632        assert!(
3633            ResponseFamily::Binomial
3634                .validate_response_degeneracy(y.view())
3635                .is_ok()
3636        );
3637    }
3638
3639    #[test]
3640    fn binomial_degeneracy_fractional_proportions_ok() {
3641        let y = arr1(&[0.0_f64, 0.5, 0.75]);
3642        assert!(
3643            ResponseFamily::Binomial
3644                .validate_response_degeneracy(y.view())
3645                .is_ok()
3646        );
3647    }
3648
3649    #[test]
3650    fn poisson_degeneracy_all_zeros_errors() {
3651        let y = arr1(&[0.0_f64, 0.0, 0.0]);
3652        let error = ResponseFamily::Poisson
3653            .validate_response_degeneracy(y.view())
3654            .expect_err("an all-zero Poisson response has no finite log-rate optimum");
3655        assert!(matches!(
3656            error.kind,
3657            ResponseDegeneracyKind::PoissonAllZeros
3658        ));
3659        assert!(
3660            error
3661                .message_for("count")
3662                .contains("at least one positive count")
3663        );
3664    }
3665
3666    #[test]
3667    fn negative_binomial_degeneracy_all_zeros_errors() {
3668        let y = arr1(&[0.0_f64, 0.0, 0.0]);
3669        let family = ResponseFamily::NegativeBinomial {
3670            theta: 1.0,
3671            theta_fixed: true,
3672        };
3673        let error = family
3674            .validate_response_degeneracy(y.view())
3675            .expect_err("an all-zero negative-binomial response has no finite log-rate optimum");
3676        assert!(matches!(
3677            error.kind,
3678            ResponseDegeneracyKind::NegativeBinomialAllZeros
3679        ));
3680    }
3681
3682    #[test]
3683    fn count_degeneracy_with_positive_event_is_valid() {
3684        let y = arr1(&[0.0_f64, 0.0, 2.0]);
3685        assert!(
3686            ResponseFamily::Poisson
3687                .validate_response_degeneracy(y.view())
3688                .is_ok()
3689        );
3690        assert!(
3691            ResponseFamily::NegativeBinomial {
3692                theta: 1.0,
3693                theta_fixed: true,
3694            }
3695            .validate_response_degeneracy(y.view())
3696            .is_ok()
3697        );
3698    }
3699
3700    #[test]
3701    fn gaussian_degeneracy_exactly_constant_ok() {
3702        // A *genuinely* zero-variance response \u{2014} every value bit-for-bit
3703        // identical \u{2014} is the well-posed constant limit, not the #332
3704        // divergence: the fit collapses to the constant (intercept = the shared
3705        // value, smooths shrunk to zero). The guard must accept it and let the
3706        // fitter return the constant surface (#1856); only a response that
3707        // varies below the sd floor without being exactly constant keeps the
3708        // rejection (see `gaussian_degeneracy_near_constant_reproducer_errors`).
3709        let y = arr1(&[1.0_f64, 1.0, 1.0]);
3710        assert!(
3711            ResponseFamily::Gaussian
3712                .validate_response_degeneracy(y.view())
3713                .is_ok()
3714        );
3715    }
3716
3717    #[test]
3718    fn gaussian_degeneracy_near_constant_reproducer_errors() {
3719        // The issue reproducer: a response with sd ~ 1e-13, well below the
3720        // `1e-10` floor, so the REML score blows up to +inf without the guard.
3721        let y = arr1(&[
3722            5.0_f64,
3723            5.0 + 1.0e-13,
3724            5.0 - 1.0e-13,
3725            5.0 + 2.0e-13,
3726            5.0 - 2.0e-13,
3727        ]);
3728        let err = ResponseFamily::Gaussian
3729            .validate_response_degeneracy(y.view())
3730            .expect_err("near-constant Gaussian response must be rejected");
3731        match err.kind {
3732            ResponseDegeneracyKind::GaussianNearConstant { sample_sd, min_sd } => {
3733                assert!(
3734                    sample_sd <= min_sd,
3735                    "guard must fire only when sample_sd ({sample_sd:.3e}) <= min_sd ({min_sd:.0e})"
3736                );
3737                assert_eq!(min_sd, GAUSSIAN_MIN_SAMPLE_SD);
3738                // The message quotes both numbers verbatim.
3739                let msg = err.message_for("y");
3740                assert!(msg.contains("effectively constant"), "msg = {msg}");
3741            }
3742            other => panic!("expected GaussianNearConstant, got {other:?}"),
3743        }
3744    }
3745
3746    #[test]
3747    fn gaussian_degeneracy_well_conditioned_ok() {
3748        // A genuinely varying response (sd ~ O(1)) is never tripped.
3749        let y = arr1(&[-2.0_f64, 0.5, 1.7, 3.0, -1.1, 2.2]);
3750        assert!(
3751            ResponseFamily::Gaussian
3752                .validate_response_degeneracy(y.view())
3753                .is_ok()
3754        );
3755    }
3756
3757    #[test]
3758    fn gaussian_degeneracy_small_signal_above_floor_ok() {
3759        // sd ~ 1e-6 is small but far above the 1e-10 floor: a legitimately
3760        // small-but-real signal (e.g. a finely-resolved measurement) must fit,
3761        // so the guard must not over-reject.
3762        let y = arr1(&[1.0_f64, 1.0 + 1.0e-6, 1.0 - 1.0e-6, 1.0 + 2.0e-6]);
3763        assert!(
3764            ResponseFamily::Gaussian
3765                .validate_response_degeneracy(y.view())
3766                .is_ok()
3767        );
3768    }
3769
3770    #[test]
3771    fn gaussian_degeneracy_single_observation_ok() {
3772        // Fewer than two observations carries no estimable scale degeneracy;
3773        // the sample-size gate handles too-small data separately.
3774        let y = arr1(&[42.0_f64]);
3775        assert!(
3776            ResponseFamily::Gaussian
3777                .validate_response_degeneracy(y.view())
3778                .is_ok()
3779        );
3780    }
3781
3782    // -----------------------------------------------------------------------
3783    // ResponseFamily::mean_clamp_bounds / response_support_bounds
3784    // -----------------------------------------------------------------------
3785
3786    #[test]
3787    fn mean_clamp_bounds_binomial_unit_interval() {
3788        assert_eq!(
3789            ResponseFamily::Binomial.mean_clamp_bounds(),
3790            Some((0.0, 1.0))
3791        );
3792    }
3793
3794    #[test]
3795    fn mean_clamp_bounds_gaussian_none() {
3796        assert_eq!(ResponseFamily::Gaussian.mean_clamp_bounds(), None);
3797    }
3798
3799    #[test]
3800    fn mean_clamp_bounds_poisson_none() {
3801        assert_eq!(ResponseFamily::Poisson.mean_clamp_bounds(), None);
3802    }
3803
3804    #[test]
3805    fn response_support_bounds_gamma_nonneg_to_inf() {
3806        assert_eq!(
3807            ResponseFamily::Gamma.response_support_bounds(),
3808            Some((0.0, f64::INFINITY))
3809        );
3810    }
3811
3812    #[test]
3813    fn response_support_bounds_binomial_unit_interval() {
3814        assert_eq!(
3815            ResponseFamily::Binomial.response_support_bounds(),
3816            Some((0.0, 1.0))
3817        );
3818    }
3819
3820    #[test]
3821    fn response_support_bounds_gaussian_none() {
3822        assert_eq!(ResponseFamily::Gaussian.response_support_bounds(), None);
3823    }
3824
3825    // -----------------------------------------------------------------------
3826    // ResponseSupportViolation::message_for
3827    // -----------------------------------------------------------------------
3828
3829    #[test]
3830    fn violation_message_names_column() {
3831        let y = arr1(&[-1.0_f64]);
3832        let err = ResponseFamily::Gamma
3833            .validate_response_support(y.view())
3834            .unwrap_err();
3835        let msg = err.message_for("my_column");
3836        assert!(msg.contains("my_column"), "message: {msg}");
3837        assert!(msg.contains("Gamma"), "message: {msg}");
3838    }
3839
3840    // -----------------------------------------------------------------------
3841    // inverse_link_to_binomial_spec
3842    // -----------------------------------------------------------------------
3843
3844    #[test]
3845    fn binomial_spec_from_logit_link_ok() {
3846        let link = InverseLink::Standard(StandardLink::Logit);
3847        assert!(inverse_link_to_binomial_spec(&link).is_ok());
3848    }
3849
3850    #[test]
3851    fn binomial_spec_from_log_link_errors() {
3852        let link = InverseLink::Standard(StandardLink::Log);
3853        assert!(inverse_link_to_binomial_spec(&link).is_err());
3854    }
3855
3856    #[test]
3857    fn binomial_spec_from_identity_link_errors() {
3858        let link = InverseLink::Standard(StandardLink::Identity);
3859        assert!(inverse_link_to_binomial_spec(&link).is_err());
3860    }
3861
3862    // -----------------------------------------------------------------------
3863    // FamilySpecKind::name / pretty_name
3864    // -----------------------------------------------------------------------
3865
3866    #[test]
3867    fn family_spec_kind_name_non_binomial_variants() {
3868        assert_eq!(FamilySpecKind::GaussianIdentity.name(), "gaussian");
3869        assert_eq!(FamilySpecKind::PoissonLog.name(), "poisson-log");
3870        assert_eq!(FamilySpecKind::GammaLog.name(), "gamma-log");
3871        assert_eq!(FamilySpecKind::TweedieLog { p: 1.5 }.name(), "tweedie-log");
3872        assert_eq!(
3873            FamilySpecKind::NegativeBinomialLog { theta: 2.0 }.name(),
3874            "negative-binomial-log"
3875        );
3876        assert_eq!(
3877            FamilySpecKind::BetaLogit { phi: 5.0 }.name(),
3878            "beta-regression-logit"
3879        );
3880        assert_eq!(FamilySpecKind::RoystonParmar.name(), "royston-parmar");
3881    }
3882
3883    #[test]
3884    fn family_spec_kind_name_binomial_variants() {
3885        assert_eq!(FamilySpecKind::BinomialLogit.name(), "binomial-logit");
3886        assert_eq!(FamilySpecKind::BinomialProbit.name(), "binomial-probit");
3887        assert_eq!(FamilySpecKind::BinomialCLogLog.name(), "binomial-cloglog");
3888    }
3889
3890    #[test]
3891    fn family_spec_kind_pretty_name_gaussian() {
3892        assert_eq!(
3893            FamilySpecKind::GaussianIdentity.pretty_name(),
3894            "Gaussian Identity"
3895        );
3896    }
3897
3898    #[test]
3899    fn family_spec_kind_pretty_name_binomial_logit() {
3900        assert_eq!(
3901            FamilySpecKind::BinomialLogit.pretty_name(),
3902            "Binomial Logit"
3903        );
3904    }
3905
3906    // -----------------------------------------------------------------------
3907    // FamilySpecKind::is_binomial and companions
3908    // -----------------------------------------------------------------------
3909
3910    #[test]
3911    fn is_binomial_true_for_all_binomial_variants() {
3912        assert!(FamilySpecKind::BinomialLogit.is_binomial());
3913        assert!(FamilySpecKind::BinomialProbit.is_binomial());
3914        assert!(FamilySpecKind::BinomialCLogLog.is_binomial());
3915    }
3916
3917    #[test]
3918    fn is_binomial_false_for_non_binomial_variants() {
3919        assert!(!FamilySpecKind::GaussianIdentity.is_binomial());
3920        assert!(!FamilySpecKind::PoissonLog.is_binomial());
3921        assert!(!FamilySpecKind::GammaLog.is_binomial());
3922        assert!(!FamilySpecKind::RoystonParmar.is_binomial());
3923        assert!(!FamilySpecKind::TweedieLog { p: 1.5 }.is_binomial());
3924        assert!(!FamilySpecKind::NegativeBinomialLog { theta: 1.0 }.is_binomial());
3925        assert!(!FamilySpecKind::BetaLogit { phi: 1.0 }.is_binomial());
3926    }
3927
3928    #[test]
3929    fn is_gaussian_identity_true_only_for_gaussian() {
3930        assert!(FamilySpecKind::GaussianIdentity.is_gaussian_identity());
3931        assert!(!FamilySpecKind::PoissonLog.is_gaussian_identity());
3932        assert!(!FamilySpecKind::BinomialLogit.is_gaussian_identity());
3933    }
3934
3935    #[test]
3936    fn is_royston_parmar_true_only_for_royston_parmar() {
3937        assert!(FamilySpecKind::RoystonParmar.is_royston_parmar());
3938        assert!(!FamilySpecKind::GaussianIdentity.is_royston_parmar());
3939        assert!(!FamilySpecKind::BinomialLogit.is_royston_parmar());
3940    }
3941
3942    #[test]
3943    fn supports_firth_iff_is_binomial() {
3944        assert!(FamilySpecKind::BinomialLogit.supports_firth());
3945        assert!(FamilySpecKind::BinomialProbit.supports_firth());
3946        assert!(FamilySpecKind::BinomialCLogLog.supports_firth());
3947        assert!(!FamilySpecKind::GaussianIdentity.supports_firth());
3948        assert!(!FamilySpecKind::PoissonLog.supports_firth());
3949        assert!(!FamilySpecKind::GammaLog.supports_firth());
3950        assert!(!FamilySpecKind::RoystonParmar.supports_firth());
3951        // The full binomial probability-link set — including LogLog and Cauchit —
3952        // supports Firth; `is_legal_cell` admits them, so `supports_firth` must too.
3953        assert!(FamilySpecKind::BinomialLogLog.supports_firth());
3954        assert!(FamilySpecKind::BinomialCauchit.supports_firth());
3955    }
3956
3957    /// Every cell `is_legal_cell` admits must classify through `kind()` without
3958    /// panicking — the "legal cells always classify" invariant. Binomial LogLog
3959    /// and Cauchit are legal (admitted at `is_legal_cell`) but previously had no
3960    /// `legal_cell_kind` arm, so `kind()` panicked on a valid, constructible spec.
3961    #[test]
3962    fn binomial_loglog_and_cauchit_are_legal_and_classify() {
3963        for link in [StandardLink::LogLog, StandardLink::Cauchit] {
3964            let inv = InverseLink::Standard(link);
3965            assert!(
3966                LikelihoodSpec::is_legal_cell(&ResponseFamily::Binomial, &inv),
3967                "Binomial + {link:?} must be a legal cell"
3968            );
3969            let spec = LikelihoodSpec::try_new(ResponseFamily::Binomial, inv)
3970                .expect("legal binomial spec must construct");
3971            // Must not panic; must land on the matching binomial kind.
3972            let kind = spec.kind();
3973            assert!(kind.is_binomial(), "kind {kind:?} must be binomial");
3974            assert!(
3975                kind.supports_firth(),
3976                "binomial probability link supports Firth"
3977            );
3978        }
3979        assert_eq!(
3980            LikelihoodSpec::try_new(
3981                ResponseFamily::Binomial,
3982                InverseLink::Standard(StandardLink::LogLog),
3983            )
3984            .unwrap()
3985            .kind()
3986            .name(),
3987            "binomial-loglog"
3988        );
3989        assert_eq!(
3990            LikelihoodSpec::try_new(
3991                ResponseFamily::Binomial,
3992                InverseLink::Standard(StandardLink::Cauchit),
3993            )
3994            .unwrap()
3995            .kind()
3996            .name(),
3997            "binomial-cauchit"
3998        );
3999    }
4000}