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    Sas,
121    BetaLogistic,
122    Identity,
123    Log,
124}
125
126impl LinkFunction {
127    #[inline]
128    pub const fn name(self) -> &'static str {
129        match self {
130            Self::Logit => "logit",
131            Self::Probit => "probit",
132            Self::CLogLog => "cloglog",
133            Self::Sas => "sas",
134            Self::BetaLogistic => "beta-logistic",
135            Self::Identity => "identity",
136            Self::Log => "log",
137        }
138    }
139}
140
141/// Legal-only link descriptor for the state-less `InverseLink::Standard` cell.
142///
143/// `Sas` / `BetaLogistic` are state-bearing and live in their own
144/// `InverseLink::Sas(_)` / `InverseLink::BetaLogistic(_)` variants. The type
145/// system enforces that fact by omitting them here, so the historical
146/// "state-less placeholder" pattern (`InverseLink::Standard(LinkFunction::Sas)`)
147/// no longer compiles.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149pub enum StandardLink {
150    Logit,
151    Probit,
152    CLogLog,
153    Identity,
154    Log,
155}
156
157impl StandardLink {
158    #[inline]
159    pub const fn name(self) -> &'static str {
160        self.as_link_function().name()
161    }
162
163    #[inline]
164    pub const fn as_link_function(self) -> LinkFunction {
165        match self {
166            Self::Logit => LinkFunction::Logit,
167            Self::Probit => LinkFunction::Probit,
168            Self::CLogLog => LinkFunction::CLogLog,
169            Self::Identity => LinkFunction::Identity,
170            Self::Log => LinkFunction::Log,
171        }
172    }
173}
174
175impl From<StandardLink> for LinkFunction {
176    #[inline]
177    fn from(link: StandardLink) -> Self {
178        link.as_link_function()
179    }
180}
181
182/// Error returned when narrowing a wide [`LinkFunction`] into a [`StandardLink`].
183/// `Sas` and `BetaLogistic` are state-bearing and have no legal `Standard(_)`
184/// representation; they must be routed through `InverseLink::Sas` /
185/// `InverseLink::BetaLogistic`.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct StateBearingLinkInStandardSlot(pub LinkFunction);
188
189impl std::fmt::Display for StateBearingLinkInStandardSlot {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        write!(
192            f,
193            "state-bearing link `{}` cannot be carried by `InverseLink::Standard`; \
194             route through `InverseLink::Sas` / `InverseLink::BetaLogistic`",
195            self.0.name()
196        )
197    }
198}
199
200impl std::error::Error for StateBearingLinkInStandardSlot {}
201
202impl TryFrom<LinkFunction> for StandardLink {
203    type Error = StateBearingLinkInStandardSlot;
204
205    #[inline]
206    fn try_from(link: LinkFunction) -> Result<Self, Self::Error> {
207        match link {
208            LinkFunction::Logit => Ok(Self::Logit),
209            LinkFunction::Probit => Ok(Self::Probit),
210            LinkFunction::CLogLog => Ok(Self::CLogLog),
211            LinkFunction::Identity => Ok(Self::Identity),
212            LinkFunction::Log => Ok(Self::Log),
213            LinkFunction::Sas | LinkFunction::BetaLogistic => {
214                Err(StateBearingLinkInStandardSlot(link))
215            }
216        }
217    }
218}
219
220/// Supported inverse-link components for convex blended inverse links.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222pub enum LinkComponent {
223    Probit,
224    Logit,
225    CLogLog,
226    LogLog,
227    Cauchit,
228}
229
230impl LinkComponent {
231    #[inline]
232    pub const fn name(self) -> &'static str {
233        match self {
234            Self::Probit => "probit",
235            Self::Logit => "logit",
236            Self::CLogLog => "cloglog",
237            Self::LogLog => "loglog",
238            Self::Cauchit => "cauchit",
239        }
240    }
241}
242
243/// User-facing configuration for a blended inverse link.
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct MixtureLinkSpec {
246    pub components: Vec<LinkComponent>,
247    /// Free logits for components [0..K-2]. The final component logit is fixed at 0.
248    pub initial_rho: Array1<f64>,
249}
250
251/// Runtime blended-link state with precomputed softmax weights.
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct MixtureLinkState {
254    pub components: Vec<LinkComponent>,
255    /// Free logits for components [0..K-2]. The final component logit is fixed at 0.
256    pub rho: Array1<f64>,
257    /// Softmax-normalized component weights (length K).
258    pub pi: Array1<f64>,
259}
260
261/// User-facing configuration for the continuous sinh-arcsinh inverse link.
262#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
263pub struct SasLinkSpec {
264    pub initial_epsilon: f64,
265    pub initial_log_delta: f64,
266}
267
268/// Runtime state shared by the two-parameter `Sas` and `BetaLogistic` links:
269/// an `epsilon` skew/asymmetry term plus a raw log-scale parameter (`log_delta`)
270/// and its derived positive companion (`delta`). The `delta` field's meaning is
271/// link-specific — see its doc — so derivative kernels must consume `log_delta`,
272/// never `delta`.
273#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
274pub struct SasLinkState {
275    pub epsilon: f64,
276    /// Raw optimization parameter. For `Sas` this is the pre-bound log-tail; for
277    /// `BetaLogistic` it is the unconstrained log geometric-mean beta shape (the
278    /// `log_shape_center` the beta-logistic kernels expect).
279    pub log_delta: f64,
280    /// Derived positive companion of `log_delta`. Its meaning depends on the link:
281    /// - `Sas`: effective tail parameter `delta = exp(B * tanh(log_delta / B))`,
282    ///   `B = SAS_LOG_DELTA_BOUND`.
283    /// - `BetaLogistic`: geometric-mean beta shape `exp(log_delta) = sqrt(a*b)`.
284    /// The beta-logistic derivative kernels take `log_delta` (the log center), so
285    /// passing this exponentiated `delta` to them would be off by an `exp`.
286    pub delta: f64,
287}
288
289/// Fixed latent Gaussian scale for the exact marginal cloglog family.
290#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
291pub struct LatentCLogLogState {
292    pub latent_sd: f64,
293}
294
295impl LatentCLogLogState {
296    #[inline]
297    pub fn new(latent_sd: f64) -> Result<Self, String> {
298        if !latent_sd.is_finite() || latent_sd < 0.0 {
299            return Err(format!(
300                "latent cloglog standard deviation must be finite and >= 0, got {latent_sd}"
301            ));
302        }
303        Ok(Self { latent_sd })
304    }
305}
306
307/// Whether the inverse link exposes the Fisher-weight jet that the Firth
308/// penalty's higher-order correction consumes. Inlined here (issue #1521) so
309/// `LikelihoodSpec::supports_firth` has no upward dependency on
310/// `solver::mixture_link` — the match is over link variants all defined in this
311/// module, so the predicate is self-contained. The canonical jet evaluation
312/// still lives in `solver::mixture_link`; this is purely the classifier.
313#[inline]
314fn inverse_link_has_fisher_weight_jet(link: &InverseLink) -> bool {
315    matches!(
316        link,
317        InverseLink::Standard(StandardLink::Logit | StandardLink::Probit | StandardLink::CLogLog,)
318            | InverseLink::LatentCLogLog(_)
319            | InverseLink::Sas(_)
320            | InverseLink::BetaLogistic(_)
321            | InverseLink::Mixture(_)
322    )
323}
324
325/// Parameterized inverse-link selector used where mu/derivatives are evaluated.
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327pub enum InverseLink {
328    Standard(StandardLink),
329    LatentCLogLog(LatentCLogLogState),
330    Sas(SasLinkState),
331    BetaLogistic(SasLinkState),
332    Mixture(MixtureLinkState),
333}
334
335impl InverseLink {
336    #[inline]
337    pub const fn link_function(&self) -> LinkFunction {
338        match self {
339            Self::Standard(link) => link.as_link_function(),
340            Self::LatentCLogLog(_) => LinkFunction::CLogLog,
341            Self::Sas(_) => LinkFunction::Sas,
342            Self::BetaLogistic(_) => LinkFunction::BetaLogistic,
343            Self::Mixture(_) => LinkFunction::Logit,
344        }
345    }
346
347    #[inline]
348    pub const fn mixture_state(&self) -> Option<&MixtureLinkState> {
349        match self {
350            Self::Mixture(state) => Some(state),
351            _ => None,
352        }
353    }
354
355    #[inline]
356    pub const fn sas_state(&self) -> Option<&SasLinkState> {
357        match self {
358            Self::Sas(state) | Self::BetaLogistic(state) => Some(state),
359            _ => None,
360        }
361    }
362
363    #[inline]
364    pub const fn latent_cloglog_state(&self) -> Option<&LatentCLogLogState> {
365        match self {
366            Self::LatentCLogLog(state) => Some(state),
367            _ => None,
368        }
369    }
370}
371
372/// Fixed prior family for smoothing parameters in joint HMC refinement.
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374pub enum RhoPrior {
375    Flat,
376    Normal {
377        mean: f64,
378        sd: f64,
379    },
380    /// Gamma(shape, rate) conjugate hyperprior on the precision lambda = exp(rho).
381    ///
382    /// The deterministic REML/LAML objective uses the MAP-in-lambda convention
383    /// and is minimized, so this contributes `rate * exp(rho) - (shape - 1) * rho`
384    /// up to an additive constant. Samplers over rho include the +rho Jacobian
385    /// from lambda = exp(rho), so their log-density contribution is
386    /// `shape * rho - rate * exp(rho)`. For a block with effective dimension n_p
387    /// and centered quadratic
388    /// `(beta - mu)'S_p(beta - mu)`, the conditional posterior is
389    /// `Gamma(shape + n_p/2, rate + quadratic/2)` and the closed-form MAP
390    /// precision is `(shape + n_p/2 - 1) / (rate + quadratic/2)`.
391    /// `Gamma(1, 0)` is the explicit flat/default case and reproduces the
392    /// current MacKay/Tipping fixed point.
393    GammaPrecision {
394        shape: f64,
395        rate: f64,
396    },
397    /// Penalized-complexity (PC) prior on the smoothing parameter
398    /// (Simpson, Rue, Riebler, Martins, Sørbye, *Statistical Science* 2017).
399    ///
400    /// A PC prior fixes a *base* model (here the infinitely-smooth limit, where
401    /// the penalized component collapses to its null space) and puts an
402    /// exponential prior on the distance away from it. For a Gaussian smooth
403    /// with precision `λ = exp(ρ)` the relevant distance is the marginal
404    /// standard-deviation scale `d = λ^{-1/2} = exp(-ρ/2)`, and a constant-rate
405    /// penalization `p(d) = θ exp(-θ d)` induces the closed-form log-prior
406    ///
407    /// ```text
408    /// log p(ρ) = ln(θ/2) − ρ/2 − θ exp(−ρ/2).
409    /// ```
410    ///
411    /// The rate `θ` is calibrated by the single interpretable tail statement
412    /// `P(d > upper) = tail_prob`, i.e. `θ = −ln(tail_prob) / upper`. The prior
413    /// is reparameterization-invariant and shrinks toward the simpler model
414    /// (an exponential wall against under-smoothing, only a gentle linear pull
415    /// toward over-smoothing), which is exactly the Occam behaviour wanted for
416    /// high-variance flexible components. The REML/LAML objective is minimized,
417    /// so this contributes `ρ/2 + θ exp(−ρ/2)` (up to an additive constant) to
418    /// the cost, with gradient `1/2 − (θ/2) exp(−ρ/2)` and (always positive)
419    /// curvature `(θ/4) exp(−ρ/2)`.
420    PenalizedComplexity {
421        /// Upper bound `U` on the distance scale `d = exp(-ρ/2)` (the marginal
422        /// SD scale of the penalized component) in the tail statement
423        /// `P(d > U) = tail_prob`. Must be finite and strictly positive.
424        upper: f64,
425        /// Tail probability `α` in `P(d > U) = α`. Must satisfy `0 < α < 1`.
426        tail_prob: f64,
427    },
428    /// Coordinate-specific priors for models whose smoothing parameters do
429    /// not share one prior family, such as nested coefficient groups.
430    Independent(Vec<RhoPrior>),
431}
432
433impl Default for RhoPrior {
434    fn default() -> Self {
435        Self::Normal { mean: 0.0, sd: 3.0 }
436    }
437}
438
439// ---------------------------------------------------------------------------
440// Unified likelihood specification
441// ---------------------------------------------------------------------------
442//
443// `LikelihoodSpec { response: ResponseFamily, link: InverseLink }` is the
444// canonical likelihood selector. `ResponseFamily` is a pure response-
445// distribution selector that carries the per-family scalars
446// (`Tweedie { p }`, `NegativeBinomial { theta }`, `Beta { phi }`); `InverseLink`
447// is the parameterized inverse-link selector. Splitting (response, link)
448// removes the drift bug that the former flat likelihood enum allowed
449// when its variant disagreed with a separately-stored `InverseLink`.
450
451/// Pure response distribution selector — no link information.
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
453pub enum ResponseFamily {
454    Gaussian,
455    Binomial,
456    Poisson,
457    Tweedie {
458        p: f64,
459    },
460    NegativeBinomial {
461        theta: f64,
462        /// `true` when `theta` was supplied by the user as a held-fixed value
463        /// (`--negative-binomial-theta`, issue #983): the fit must use exactly
464        /// this overdispersion — `Var(y) = μ + μ²/θ`, IRLS weight
465        /// `W = μθ/(θ+μ)`, coefficients/covariance/SEs all reflect it — and
466        /// the inner solver must never overwrite it. `false` means `theta` is
467        /// the running seed/estimate refined from the data each inner solve
468        /// (the #802 default). Carried on the family variant — the canonical
469        /// `theta` store — so the estimated-vs-fixed contract can never desync
470        /// from the value itself; `default_scale_metadata` derives the
471        /// matching scale variant.
472        theta_fixed: bool,
473    },
474    Beta {
475        phi: f64,
476    },
477    Gamma,
478    RoystonParmar,
479}
480
481impl ResponseFamily {
482    #[inline]
483    pub const fn name(&self) -> &'static str {
484        match self {
485            Self::Gaussian => "gaussian",
486            Self::Binomial => "binomial",
487            Self::Poisson => "poisson",
488            Self::Tweedie { .. } => "tweedie",
489            Self::NegativeBinomial { .. } => "negative-binomial",
490            Self::Beta { .. } => "beta",
491            Self::Gamma => "gamma",
492            Self::RoystonParmar => "royston-parmar",
493        }
494    }
495
496    /// Closed-interval bounds for the mean (response-scale) of this family.
497    ///
498    /// Used by predict-side CI clamps that need to keep transformed bounds
499    /// within the support of the response. Beta uses strict-open `(1e-10, 1 − 1e-10)`
500    /// to avoid logit singularities; Binomial / Royston-Parmar use the closed
501    /// `[0, 1]` since they are evaluated post-transformation. Unbounded
502    /// (continuous-real or non-negative-real) families return `None` — the
503    /// caller should not clamp.
504    #[inline]
505    pub fn mean_clamp_bounds(&self) -> Option<(f64, f64)> {
506        match self {
507            Self::Binomial | Self::RoystonParmar => Some((0.0, 1.0)),
508            Self::Beta { .. } => Some((1e-10, 1.0 - 1e-10)),
509            Self::Gaussian
510            | Self::Poisson
511            | Self::Tweedie { .. }
512            | Self::NegativeBinomial { .. }
513            | Self::Gamma => None,
514        }
515    }
516
517    /// Closed numeric bounds of the **response support** — the closure of the
518    /// set of values a single observation `Y` can take — used to clamp the
519    /// *observation (prediction) interval* so a predictive band never reports
520    /// values the response can never attain.
521    ///
522    /// This is deliberately distinct from [`Self::mean_clamp_bounds`], which
523    /// governs the *mean* (confidence) interval. `mean_clamp_bounds` returns
524    /// `None` for the non-negative-real families (Poisson / Tweedie /
525    /// NegativeBinomial / Gamma) because their default mean interval is built
526    /// by transforming the η endpoints through a positive inverse link, which
527    /// cannot escape the support. The observation interval, by contrast, is the
528    /// symmetric response-scale band `μ ± z·σ_pred`; for a small fitted mean its
529    /// lower endpoint crosses below the support floor (e.g. a Poisson count band
530    /// going negative), so it must be floored at the response support here.
531    ///
532    /// The lower edge is the infimum of the support (`0` for every non-negative
533    /// family, including the open-at-zero Gamma, whose predictive lower bound is
534    /// reported at the boundary `0`). The upper edge is `+∞` where the response
535    /// is unbounded above, which leaves the upper band untouched, or `1` for the
536    /// `[0, 1]`-valued families. `None` means the response is supported on the
537    /// whole real line (Gaussian) or has its support enforced downstream
538    /// (Royston–Parmar), and the predictive band is passed through unclamped.
539    ///
540    /// The match arms mirror [`Self::response_support_contains`]: a new family
541    /// must update both together so the support a value is validated against and
542    /// the support a predictive band is clamped to stay consistent.
543    #[inline]
544    pub fn response_support_bounds(&self) -> Option<(f64, f64)> {
545        match self {
546            Self::Gamma | Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
547                Some((0.0, f64::INFINITY))
548            }
549            Self::Beta { .. } | Self::Binomial => Some((0.0, 1.0)),
550            Self::Gaussian | Self::RoystonParmar => None,
551        }
552    }
553
554    /// Per-family textual description of the response-support requirement.
555    /// `None` means the family is supported on the entire real line at the
556    /// validation layer (Gaussian) or has its support enforced by a downstream
557    /// pathway (RoystonParmar via the survival pipeline).
558    ///
559    /// `Binomial` is the scalar Bernoulli-logit family: with no per-row trial
560    /// count `mᵢ`, the log-likelihood is `ℓ(η) = y·η − log(1 + eη)`, which is
561    /// unbounded above for `y ∉ {0, 1}` (as `η → ∞`, `ℓ ~ (y − 1)·η`), and the
562    /// binomial deviance term `(1 − y)·log((1 − y)/(1 − μ))` leaves its domain
563    /// for `y > 1`. The family is therefore only well-posed for `y ∈ {0, 1}`,
564    /// which the support check enforces up front rather than deferring to the
565    /// downstream binarity heuristic.
566    #[inline]
567    pub fn response_support_requirement(&self) -> Option<&'static str> {
568        match self {
569            Self::Gamma => Some("strictly positive response values (y > 0)"),
570            Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
571                Some("non-negative response values (y ≥ 0)")
572            }
573            Self::Beta { .. } => Some(
574                "response values strictly in the open interval (0, 1) \
575                 (a binary {0, 1} response is a Binomial GLM, not Beta; route it through the Binomial family instead)",
576            ),
577            Self::Binomial => Some("binary response values (y ∈ {0, 1})"),
578            Self::Gaussian | Self::RoystonParmar => None,
579        }
580    }
581
582    /// Predicate that returns `true` iff `yi` lies in this family's response
583    /// support. Only meaningful for families with a non-trivial domain
584    /// constraint at the validation layer; `validate_response_support` calls
585    /// this only after `response_support_requirement` returns `Some`, so the
586    /// "unconstrained" families (Gaussian / RoystonParmar) never hit this code
587    /// path.
588    ///
589    /// `Binomial` accepts only an (exactly) binary value: `y` must equal `0` or
590    /// `1` to within `BINOMIAL_BINARY_TOL` — the same `{0, 1}` test used by the
591    /// auto-inference and degeneracy paths — because the scalar Bernoulli-logit
592    /// likelihood is unbounded for any other value (see
593    /// `response_support_requirement`).
594    #[inline]
595    fn response_support_contains(&self, yi: f64) -> bool {
596        match self {
597            Self::Gamma => yi.is_finite() && yi > 0.0,
598            Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
599                yi.is_finite() && yi >= 0.0
600            }
601            Self::Beta { .. } => yi.is_finite() && yi > 0.0 && yi < 1.0,
602            Self::Binomial => {
603                yi.is_finite()
604                    && ((yi - 0.0).abs() < BINOMIAL_BINARY_TOL
605                        || (yi - 1.0).abs() < BINOMIAL_BINARY_TOL)
606            }
607            Self::Gaussian | Self::RoystonParmar => true,
608        }
609    }
610
611    /// Human-readable family label used in domain-violation error messages
612    /// (capitalised to match user-facing prose, distinct from `name()` which
613    /// returns the lowercase canonical identifier).
614    #[inline]
615    fn response_support_label(&self) -> &'static str {
616        match self {
617            Self::Gaussian => "Gaussian",
618            Self::Binomial => "Binomial",
619            Self::Poisson => "Poisson",
620            Self::Tweedie { .. } => "Tweedie",
621            Self::NegativeBinomial { .. } => "Negative-Binomial",
622            Self::Beta { .. } => "Beta",
623            Self::Gamma => "Gamma",
624            Self::RoystonParmar => "Royston-Parmar",
625        }
626    }
627
628    /// Validate that every element of `y` lies in this family's response
629    /// support. The check is the upfront, fit-blocking enforcement of the
630    /// family's distributional support — e.g. Gamma rejects `y ≤ 0` because
631    /// the log-likelihood contains `log(y)`, Poisson rejects `y < 0` because
632    /// the log-mass contains `log(y!)`.
633    ///
634    /// Returns `Ok(())` for families whose support is the entire real line at
635    /// this layer (Gaussian) or whose support is enforced by a downstream
636    /// pathway (RoystonParmar via the survival pipeline). `Binomial` is
637    /// enforced here: only `y ∈ {0, 1}` keeps the scalar Bernoulli-logit
638    /// likelihood bounded.
639    ///
640    /// Up to `ResponseSupportViolation::MAX_REPORTED` offending row indices
641    /// are returned in the violation so the message stays bounded on large
642    /// datasets while still identifying offending rows.
643    pub fn validate_response_support(
644        &self,
645        y: ArrayView1<'_, f64>,
646    ) -> Result<(), ResponseSupportViolation> {
647        let requirement = match self.response_support_requirement() {
648            Some(r) => r,
649            None => return Ok(()),
650        };
651        let mut offending: Vec<(usize, f64)> = Vec::new();
652        let mut total_violations: usize = 0;
653        for (i, &yi) in y.iter().enumerate() {
654            if !self.response_support_contains(yi) {
655                total_violations += 1;
656                if offending.len() < ResponseSupportViolation::MAX_REPORTED {
657                    offending.push((i, yi));
658                }
659            }
660        }
661        if total_violations == 0 {
662            Ok(())
663        } else {
664            Err(ResponseSupportViolation {
665                family_label: self.response_support_label(),
666                requirement,
667                offending,
668                total_violations,
669            })
670        }
671    }
672
673    /// Detect a *degenerate* response: one whose value distribution makes the
674    /// family's REML log-likelihood non-finite even though every individual
675    /// `y_i` lies inside the family's distributional support.
676    ///
677    /// Symmetric counterpart to [`Self::validate_response_support`]: support
678    /// rejects out-of-domain *values* (e.g. a negative Poisson count); this
679    /// rejects *distributions* that send the saturated MLE to a boundary at
680    /// which the score diverges. Each family answers the question for itself
681    /// — adding a new family does not require touching workflow.rs.
682    ///
683    /// Concretely:
684    /// * `Binomial` — refuses an all-zero or all-one response: the saturated
685    ///   logit is ±∞ and the REML score is +∞ (issue #331).
686    /// * Every other family currently returns `Ok(())` at this layer — the
687    ///   support check already guarantees enough variation to make the
688    ///   log-likelihood finite.
689    pub fn validate_response_degeneracy(
690        &self,
691        y: ArrayView1<'_, f64>,
692    ) -> Result<(), ResponseDegeneracy> {
693        match self {
694            Self::Binomial => {
695                let mut saw_zero = false;
696                let mut saw_one = false;
697                for &yi in y.iter() {
698                    if (yi - 0.0).abs() < BINOMIAL_BINARY_TOL {
699                        saw_zero = true;
700                    } else if (yi - 1.0).abs() < BINOMIAL_BINARY_TOL {
701                        saw_one = true;
702                    }
703                    if saw_zero && saw_one {
704                        return Ok(());
705                    }
706                }
707                let kind = if saw_one {
708                    ResponseDegeneracyKind::BinomialAllOnes
709                } else if saw_zero {
710                    ResponseDegeneracyKind::BinomialAllZeros
711                } else {
712                    // Reachable only for an empty response: the support check
713                    // (`validate_response_support`) has already rejected any
714                    // non-binary value, so every present `yᵢ` is exactly 0 or 1
715                    // and at least one of `saw_zero`/`saw_one` is set whenever
716                    // `y` is non-empty. An empty response carries no
717                    // saturated-boundary degeneracy, so accept it here.
718                    return Ok(());
719                };
720                Err(ResponseDegeneracy {
721                    family_label: self.response_support_label(),
722                    kind,
723                })
724            }
725            Self::Gaussian => {
726                // A Gaussian fit's marginal REML log-likelihood carries a
727                // `−n/2·log σ²` term; for an effectively-constant response the
728                // ML scale `σ → 0` drives it to `+∞`, so the outer objective
729                // rejects every seed with "reml_score must be finite, got inf"
730                // (#332). Reject pre-fit when the two-pass, mean-centred sample
731                // sd is at or below `GAUSSIAN_MIN_SAMPLE_SD`. Fewer than two
732                // observations carries no estimable scale degeneracy (the
733                // sample-size gate handles too-small data), and any non-finite
734                // value is left to the dedicated finiteness checks rather than
735                // poisoning the sd, so it is skipped here.
736                //
737                // Exception (#1856): a *genuinely* zero-variance response —
738                // every observation bit-for-bit identical — is not the
739                // pathological near-constant case above but the well-posed
740                // degenerate limit. The penalized fit collapses cleanly to the
741                // constant (intercept = the shared value, every smooth shrunk
742                // to zero) and predicts that constant, so it must fit rather
743                // than be rejected. Only a response that *varies* below the sd
744                // floor without being exactly constant keeps the #332
745                // rejection, whose REML score genuinely diverges to +∞.
746                let mut count = 0usize;
747                let mut mean = 0.0f64;
748                for &yi in y.iter() {
749                    if !yi.is_finite() {
750                        return Ok(());
751                    }
752                    count += 1;
753                    mean += yi;
754                }
755                if count < 2 {
756                    return Ok(());
757                }
758                mean /= count as f64;
759                let mut sumsq = 0.0f64;
760                for &yi in y.iter() {
761                    let d = yi - mean;
762                    sumsq += d * d;
763                }
764                let sample_sd = (sumsq / (count as f64 - 1.0)).sqrt();
765                if sample_sd <= GAUSSIAN_MIN_SAMPLE_SD {
766                    // Genuine zero variance (all values exactly equal) is the
767                    // well-posed constant limit, not the #332 divergence: accept
768                    // it and let the fitter return the constant surface (#1856).
769                    let first = y[0];
770                    if y.iter().all(|&yi| yi == first) {
771                        return Ok(());
772                    }
773                    return Err(ResponseDegeneracy {
774                        family_label: self.response_support_label(),
775                        kind: ResponseDegeneracyKind::GaussianNearConstant {
776                            sample_sd,
777                            min_sd: GAUSSIAN_MIN_SAMPLE_SD,
778                        },
779                    });
780                }
781                Ok(())
782            }
783            Self::Poisson
784            | Self::Tweedie { .. }
785            | Self::NegativeBinomial { .. }
786            | Self::Beta { .. }
787            | Self::Gamma
788            | Self::RoystonParmar => Ok(()),
789        }
790    }
791
792    /// Auto-infer a likelihood family when the user did not specify one.
793    ///
794    /// Policy:
795    ///   * A string-valued (`Categorical`) response column is refused —
796    ///     numeric-encoded level indices (e.g. `"yes"`/`"no"` → `0.0`/`1.0`)
797    ///     would otherwise be silently interpreted as a binary outcome,
798    ///     producing a probability model the user never asked for.
799    ///   * A strictly-binary numeric response (`Binary` kind, or `Numeric`
800    ///     with only `{0, 1}` values) maps to `Binomial`.
801    ///   * A non-negative integer-valued count response (every value finite,
802    ///     `>= 0`, and within [`COUNT_INTEGER_TOL`] of an integer) that reaches
803    ///     beyond the binary `{0, 1}` window (i.e. carries at least one value
804    ///     `>= 2`) maps to `Poisson` (log link). This is the "magic-by-default"
805    ///     count detection: mgcv/statsmodels users expect `0,1,2,3,...` to fit a
806    ///     Poisson GLM, not an identity-link Gaussian.
807    ///   * Anything else (any fractional or negative value) maps to `Gaussian`.
808    ///
809    /// The fallback to `is_binary_response` inside the `Numeric` arm is what
810    /// historically lived directly inside `resolve_family`; centralising the
811    /// policy here means every entry point (formula API, CLI, future bindings)
812    /// gets the same default-inference behaviour.
813    pub fn infer_from_response(
814        y: ArrayView1<'_, f64>,
815        y_kind: ResponseColumnKind,
816    ) -> Result<Self, ResponseInferenceRefusal> {
817        match y_kind {
818            ResponseColumnKind::Categorical { levels } => Err(ResponseInferenceRefusal {
819                reason: ResponseInferenceRefusalReason::NonNumericResponse,
820                levels,
821            }),
822            ResponseColumnKind::Binary => Ok(Self::Binomial),
823            ResponseColumnKind::Numeric => {
824                let binary = !y.is_empty()
825                    && y.iter().all(|v| {
826                        v.is_finite()
827                            && ((*v - 0.0).abs() < BINOMIAL_BINARY_TOL
828                                || (*v - 1.0).abs() < BINOMIAL_BINARY_TOL)
829                    });
830                if binary {
831                    return Ok(Self::Binomial);
832                }
833                // Count signature: every value finite, non-negative, and an
834                // integer within `COUNT_INTEGER_TOL`, with at least one value
835                // `>= 2` so it is not the (already-handled) binary case and not
836                // a degenerate all-zero column. A single fractional or negative
837                // value disqualifies the whole response, keeping continuous and
838                // signed data on the conservative Gaussian default.
839                let count = !y.is_empty()
840                    && y.iter().all(|v| {
841                        v.is_finite() && *v >= 0.0 && (*v - v.round()).abs() <= COUNT_INTEGER_TOL
842                    })
843                    && y.iter().any(|v| *v >= 2.0 - COUNT_INTEGER_TOL);
844                if count {
845                    Ok(Self::Poisson)
846                } else {
847                    Ok(Self::Gaussian)
848                }
849            }
850        }
851    }
852}
853
854/// Domain-violation detail produced by [`ResponseFamily::validate_response_support`].
855///
856/// Owns its own `Display` impl so call sites in the workflow, the CLI, and the
857/// external-design GLM path produce identical user-facing prose. The
858/// `total_violations` counter is kept distinct from `offending.len()` so the
859/// message can honestly say `(N total)` even when only the first
860/// `MAX_REPORTED` indices are surfaced.
861#[derive(Debug, Clone)]
862pub struct ResponseSupportViolation {
863    pub family_label: &'static str,
864    pub requirement: &'static str,
865    pub offending: Vec<(usize, f64)>,
866    pub total_violations: usize,
867}
868
869impl ResponseSupportViolation {
870    /// Maximum number of offending row indices reported in the error message.
871    /// Keeps the message bounded on large-scale data while still pointing
872    /// the user at concrete bad rows to inspect.
873    pub const MAX_REPORTED: usize = 5;
874
875    /// Format the violation against a specific response column name. The
876    /// column name is supplied by the caller because [`ResponseFamily`] does
877    /// not know which column the user pointed at.
878    pub fn message_for(&self, response_name: &str) -> String {
879        let shown = self
880            .offending
881            .iter()
882            .map(|(i, v)| format!("y[{i}]={v}"))
883            .collect::<Vec<_>>()
884            .join(", ");
885        let more = if self.total_violations > self.offending.len() {
886            format!(", ... ({} total)", self.total_violations)
887        } else {
888            String::new()
889        };
890        format!(
891            "{family} family requires {req}; response column '{name}' violates this constraint at row(s) [{shown}{more}]",
892            family = self.family_label,
893            req = self.requirement,
894            name = response_name,
895        )
896    }
897}
898
899impl std::fmt::Display for ResponseSupportViolation {
900    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
901        f.write_str(&self.message_for("y"))
902    }
903}
904
905impl std::error::Error for ResponseSupportViolation {}
906
907/// Absolute tolerance for the exact-`{0, 1}` test that defines the scalar
908/// Bernoulli (`Binomial`) response support.
909///
910/// The scalar `Binomial` family carries no per-row trial count, so its
911/// log-likelihood is the Bernoulli/soft-label cross-entropy
912/// `ℓ(η) = y·η − log(1 + eη)`, which is unbounded above for `y ∉ {0, 1}`.
913/// Both the auto-inference (`infer_from_response`) and degeneracy
914/// (`validate_response_degeneracy`) paths classify a value as binary by the
915/// same `1e-12` window; the support check shares this single threshold so the
916/// three layers agree on exactly which responses are admissible.
917pub const BINOMIAL_BINARY_TOL: f64 = 1.0e-12;
918
919/// Minimum admissible sample standard deviation for a `Gaussian` response.
920///
921/// A response whose two-pass, mean-centred sample sd is at or below this
922/// threshold is *effectively constant* in `f64` arithmetic: the marginal REML
923/// log-likelihood carries a `−n/2·log σ²` term that diverges to `+∞` as the
924/// fitted scale `σ → 0`, so the outer objective rejects every seed with
925/// `reml_score must be finite, got inf` (#332). The bound is chosen well below
926/// any well-conditioned scientific signal (genuine data has sd many orders of
927/// magnitude larger) yet above the f64 round-off floor, so it never trips a
928/// real fit while catching responses that carry no signal (e.g. a column read
929/// in the wrong scale, or a constant accidentally fed as the response).
930///
931/// One case below the floor is *not* rejected: a genuinely zero-variance
932/// response whose values are all bit-for-bit identical. That is the well-posed
933/// constant limit (the fit collapses to the constant, smooths shrunk to zero)
934/// rather than the divergent near-constant case, so it fits (#1856); only a
935/// response that varies below this floor without being exactly constant is
936/// rejected.
937pub const GAUSSIAN_MIN_SAMPLE_SD: f64 = 1.0e-10;
938
939/// Round tolerance for recognising an integer-valued (count) response.
940///
941/// `infer_from_response` classifies a numeric response as a Poisson count when
942/// every value is finite, non-negative, and within this window of its nearest
943/// non-negative integer. The threshold is looser than [`BINOMIAL_BINARY_TOL`]
944/// because count columns frequently arrive as `f64` round-trips of integers
945/// (CSV parse, integer→double promotion) that accumulate ULP-scale error well
946/// above `1e-12`; `1e-9` admits those without ever matching genuinely
947/// continuous data, whose fractional parts are O(1).
948pub const COUNT_INTEGER_TOL: f64 = 1.0e-9;
949
950/// Classifier for a [`ResponseDegeneracy`]. Each variant carries the family-
951/// specific evidence the caller needs to format a useful message without
952/// having to re-derive the diagnostic.
953#[derive(Debug, Clone)]
954pub enum ResponseDegeneracyKind {
955    /// Bernoulli / Binomial response with every observed value equal to 0.
956    BinomialAllZeros,
957    /// Bernoulli / Binomial response with every observed value equal to 1.
958    BinomialAllOnes,
959    /// Gaussian response that is effectively constant in `f64` arithmetic
960    /// (sample standard deviation at or below [`GAUSSIAN_MIN_SAMPLE_SD`]). The
961    /// marginal REML log-likelihood `−n/2·log σ²` diverges to `+∞` as the
962    /// fitted scale `σ → 0`, so every outer evaluation rejects with a
963    /// non-finite score. Carries the observed `sample_sd` and the `min_sd`
964    /// threshold so the message can quote both verbatim (#332).
965    GaussianNearConstant {
966        /// The two-pass, mean-centred sample standard deviation of the response.
967        sample_sd: f64,
968        /// The rejection threshold ([`GAUSSIAN_MIN_SAMPLE_SD`]).
969        min_sd: f64,
970    },
971}
972
973/// Degenerate-response detail produced by
974/// [`ResponseFamily::validate_response_degeneracy`].
975///
976/// Mirrors [`ResponseSupportViolation`]: it owns its own `Display` and
977/// `message_for(column_name)` so call sites in the workflow, the CLI, and
978/// any future binding produce identical user-facing prose without coupling
979/// each one to the family-internal classifier.
980#[derive(Debug, Clone)]
981pub struct ResponseDegeneracy {
982    pub family_label: &'static str,
983    pub kind: ResponseDegeneracyKind,
984}
985
986impl ResponseDegeneracy {
987    /// Format the degeneracy against a specific response column name. The
988    /// column name is supplied by the caller because [`ResponseFamily`] does
989    /// not know which column the user pointed at.
990    pub fn message_for(&self, response_name: &str) -> String {
991        match self.kind {
992            ResponseDegeneracyKind::BinomialAllZeros => format!(
993                "{family} response '{name}' is degenerate: all values are 0 (no events). \
994                 The maximum-likelihood logit is −∞ at this boundary, so the REML score \
995                 is not finite. Fix: ensure the response contains at least one 0 and \
996                 at least one 1 (e.g. drop the offending subgroup, or refit on a pooled \
997                 sample that includes both classes).",
998                family = self.family_label,
999                name = response_name,
1000            ),
1001            ResponseDegeneracyKind::BinomialAllOnes => format!(
1002                "{family} response '{name}' is degenerate: all values are 1 (no non-events). \
1003                 The maximum-likelihood logit is +∞ at this boundary, so the REML score \
1004                 is not finite. Fix: ensure the response contains at least one 0 and \
1005                 at least one 1 (e.g. drop the offending subgroup, or refit on a pooled \
1006                 sample that includes both classes).",
1007                family = self.family_label,
1008                name = response_name,
1009            ),
1010            ResponseDegeneracyKind::GaussianNearConstant { sample_sd, min_sd } => format!(
1011                "{family} response '{name}' is effectively constant (sample sd ~ {sample_sd:.3e} \
1012                 <= {min_sd:.0e}); the marginal REML log-likelihood −n/2·log σ² diverges to \
1013                 +∞ as σ → 0. Fix: check the response column units (is it being read in the \
1014                 right scale?), centre/rescale the response, or drop the column if it carries \
1015                 no signal.",
1016                family = self.family_label,
1017                name = response_name,
1018            ),
1019        }
1020    }
1021}
1022
1023impl std::fmt::Display for ResponseDegeneracy {
1024    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1025        f.write_str(&self.message_for("y"))
1026    }
1027}
1028
1029impl std::error::Error for ResponseDegeneracy {}
1030
1031/// Caller-supplied description of the response column's *source* kind.
1032///
1033/// `Categorical { levels }` flags a column that arrived as non-numeric strings
1034/// (the ingest layer encoded its levels to `0.0, 1.0, ...` indices) — the
1035/// `levels` list is preserved so the auto-inference refusal can echo them
1036/// back to the user verbatim. `Binary` is the ingest-layer signal that a
1037/// numeric column already contains only `{0, 1}` (used to short-circuit the
1038/// scan inside [`ResponseFamily::infer_from_response`]). `Numeric` is the
1039/// generic continuous case.
1040#[derive(Debug, Clone)]
1041pub enum ResponseColumnKind {
1042    Numeric,
1043    Binary,
1044    Categorical { levels: Vec<String> },
1045}
1046
1047/// Reason [`ResponseFamily::infer_from_response`] refused to pick a default
1048/// family. Kept as an enum so future policy extensions (e.g. "refuse on
1049/// constant response" — currently a separate CLI-side check) can be added
1050/// without breaking the call site's match arms.
1051#[derive(Debug, Clone)]
1052pub enum ResponseInferenceRefusalReason {
1053    NonNumericResponse,
1054}
1055
1056/// Auto-inference refusal carrying the levels seen in the source column so
1057/// the workflow error can echo them in its message.
1058#[derive(Debug, Clone)]
1059pub struct ResponseInferenceRefusal {
1060    pub reason: ResponseInferenceRefusalReason,
1061    pub levels: Vec<String>,
1062}
1063
1064impl ResponseInferenceRefusal {
1065    /// Format the refusal against a specific response column name.
1066    pub fn message_for(&self, response_name: &str) -> String {
1067        match self.reason {
1068            ResponseInferenceRefusalReason::NonNumericResponse => {
1069                let n = self.levels.len().min(5);
1070                let head = self
1071                    .levels
1072                    .iter()
1073                    .take(n)
1074                    .map(|s| format!("'{s}'"))
1075                    .collect::<Vec<_>>()
1076                    .join(", ");
1077                let preview = if self.levels.len() > n {
1078                    format!("[{head}, ...]")
1079                } else {
1080                    format!("[{head}]")
1081                };
1082                format!(
1083                    "response column '{name}' contains non-numeric values {preview}. \
1084                     Did you mean to use family='binomial' for a binary outcome, \
1085                     or does '{name}' contain categorical labels that should be encoded first?",
1086                    name = response_name,
1087                    preview = preview,
1088                )
1089            }
1090        }
1091    }
1092}
1093
1094impl std::fmt::Display for ResponseInferenceRefusal {
1095    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1096        f.write_str(&self.message_for("y"))
1097    }
1098}
1099
1100impl std::error::Error for ResponseInferenceRefusal {}
1101
1102/// Unified likelihood specification: response distribution + parameterized link.
1103///
1104/// `ResponseFamily` carries the per-family scalars (Tweedie p, NegBin theta,
1105/// Beta phi); `InverseLink` carries the parameterized link state. Together
1106/// they replace the former flat likelihood enum.
1107///
1108/// Only the legal `(response, link)` cells enumerated by [`LikelihoodSpec::kind`]
1109/// are representable through the public surface: [`LikelihoodSpec::try_new`]
1110/// validates the legal matrix on construction, and deserialization routes
1111/// through [`LikelihoodSpecWire`] (`#[serde(try_from / into)]`) so saved bytes
1112/// cannot resurrect an illegal cell. The on-wire shape is byte-identical to the
1113/// historical `{ response, link }` struct, so legal saved models load unchanged.
1114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1115#[serde(try_from = "LikelihoodSpecWire", into = "LikelihoodSpecWire")]
1116pub struct LikelihoodSpec {
1117    pub response: ResponseFamily,
1118    pub link: InverseLink,
1119}
1120
1121/// Transparent serde shadow of [`LikelihoodSpec`] with the identical wire shape
1122/// (`response`, `link`). All (de)serialization of `LikelihoodSpec` routes
1123/// through this type so the legal-matrix check in
1124/// [`TryFrom<LikelihoodSpecWire>`] runs on every load, closing the
1125/// saved-bytes hole: an illegal `(response, link)` cell deserializes into a
1126/// serde error instead of a silently-masked spec.
1127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1128pub struct LikelihoodSpecWire {
1129    pub response: ResponseFamily,
1130    pub link: InverseLink,
1131}
1132
1133impl From<LikelihoodSpec> for LikelihoodSpecWire {
1134    #[inline]
1135    fn from(spec: LikelihoodSpec) -> Self {
1136        Self {
1137            response: spec.response,
1138            link: spec.link,
1139        }
1140    }
1141}
1142
1143impl TryFrom<LikelihoodSpecWire> for LikelihoodSpec {
1144    type Error = IllegalLikelihoodCell;
1145
1146    #[inline]
1147    fn try_from(wire: LikelihoodSpecWire) -> Result<Self, Self::Error> {
1148        Self::try_new(wire.response, wire.link)
1149    }
1150}
1151
1152/// Error returned when an illegal `(ResponseFamily, InverseLink)` cell is
1153/// presented to [`LikelihoodSpec::try_new`] or surfaced during
1154/// deserialization. Only the cells enumerated by [`LikelihoodSpec::kind`] are
1155/// legal; every other product cell would silently mask a wrong response
1156/// transformation (e.g. `Poisson + Identity` predicting `μ = η`, which can go
1157/// negative).
1158#[derive(Debug, Clone, PartialEq)]
1159pub struct IllegalLikelihoodCell {
1160    pub response: &'static str,
1161    pub link: &'static str,
1162}
1163
1164impl std::fmt::Display for IllegalLikelihoodCell {
1165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1166        write!(
1167            f,
1168            "illegal likelihood cell: response `{}` does not admit inverse link `{}`. \
1169             Each non-binomial family is pinned to one link (Gaussian/Royston-Parmar→identity, \
1170             Poisson/Gamma/Tweedie/Negative-Binomial→log, Beta→logit); the binomial family \
1171             admits logit/probit/cloglog and the latent-cloglog/SAS/beta-logistic/blended \
1172             links, but not identity/log.",
1173            self.response, self.link
1174        )
1175    }
1176}
1177
1178impl std::error::Error for IllegalLikelihoodCell {}
1179
1180/// Legal-only enumeration of the `(ResponseFamily, InverseLink)` cells the
1181/// engine recognises. `LikelihoodSpec` is the product type with ~40 nominal
1182/// cells (8 response variants × 5 inverse-link variants), but only the cells
1183/// listed here are honoured by the family math; the rest are silently masked
1184/// by fallback arms. `FamilySpecKind` is the canonical projection used by
1185/// naming, predicates, and dispatch.
1186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1187pub enum FamilySpecKind {
1188    GaussianIdentity,
1189    PoissonLog,
1190    GammaLog,
1191    TweedieLog { p: f64 },
1192    NegativeBinomialLog { theta: f64 },
1193    BetaLogit { phi: f64 },
1194    RoystonParmar,
1195    BinomialLogit,
1196    BinomialProbit,
1197    BinomialCLogLog,
1198    BinomialLatentCLogLog(LatentCLogLogState),
1199    BinomialSas(SasLinkState),
1200    BinomialBetaLogistic(SasLinkState),
1201    BinomialMixture(MixtureLinkState),
1202}
1203
1204impl FamilySpecKind {
1205    /// Short identifier matching the legacy `LikelihoodSpec::name()` strings.
1206    #[inline]
1207    pub const fn name(&self) -> &'static str {
1208        match self {
1209            Self::GaussianIdentity => "gaussian",
1210            Self::PoissonLog => "poisson-log",
1211            Self::TweedieLog { .. } => "tweedie-log",
1212            Self::NegativeBinomialLog { .. } => "negative-binomial-log",
1213            Self::BetaLogit { .. } => "beta-regression-logit",
1214            Self::GammaLog => "gamma-log",
1215            Self::RoystonParmar => "royston-parmar",
1216            Self::BinomialLogit => "binomial-logit",
1217            Self::BinomialProbit => "binomial-probit",
1218            Self::BinomialCLogLog => "binomial-cloglog",
1219            Self::BinomialLatentCLogLog(_) => "latent-cloglog-binomial",
1220            Self::BinomialSas(_) => "binomial-sas",
1221            Self::BinomialBetaLogistic(_) => "binomial-beta-logistic",
1222            Self::BinomialMixture(_) => "binomial-blended-inverse-link",
1223        }
1224    }
1225
1226    /// Human-readable label matching the legacy `LikelihoodSpec::pretty_name()` strings.
1227    #[inline]
1228    pub const fn pretty_name(&self) -> &'static str {
1229        match self {
1230            Self::GaussianIdentity => "Gaussian Identity",
1231            Self::PoissonLog => "Poisson Log",
1232            Self::TweedieLog { .. } => "Tweedie Log",
1233            Self::NegativeBinomialLog { .. } => "Negative-Binomial Log",
1234            Self::BetaLogit { .. } => "Beta Regression Logit",
1235            Self::GammaLog => "Gamma Log",
1236            Self::RoystonParmar => "Royston Parmar",
1237            Self::BinomialLogit => "Binomial Logit",
1238            Self::BinomialProbit => "Binomial Probit",
1239            Self::BinomialCLogLog => "Binomial CLogLog",
1240            Self::BinomialLatentCLogLog(_) => "Latent CLogLog Binomial",
1241            Self::BinomialSas(_) => "Binomial SAS",
1242            Self::BinomialBetaLogistic(_) => "Binomial Beta-Logistic",
1243            Self::BinomialMixture(_) => "Binomial Blended Inverse-Link",
1244        }
1245    }
1246
1247    #[inline]
1248    pub const fn is_binomial(&self) -> bool {
1249        matches!(
1250            self,
1251            Self::BinomialLogit
1252                | Self::BinomialProbit
1253                | Self::BinomialCLogLog
1254                | Self::BinomialLatentCLogLog(_)
1255                | Self::BinomialSas(_)
1256                | Self::BinomialBetaLogistic(_)
1257                | Self::BinomialMixture(_)
1258        )
1259    }
1260
1261    #[inline]
1262    pub const fn is_gaussian_identity(&self) -> bool {
1263        matches!(self, Self::GaussianIdentity)
1264    }
1265
1266    #[inline]
1267    pub const fn is_royston_parmar(&self) -> bool {
1268        matches!(self, Self::RoystonParmar)
1269    }
1270
1271    #[inline]
1272    pub const fn is_latent_cloglog(&self) -> bool {
1273        matches!(self, Self::BinomialLatentCLogLog(_))
1274    }
1275
1276    #[inline]
1277    pub const fn is_binomial_mixture(&self) -> bool {
1278        matches!(self, Self::BinomialMixture(_))
1279    }
1280
1281    #[inline]
1282    pub const fn is_binomial_sas(&self) -> bool {
1283        matches!(self, Self::BinomialSas(_))
1284    }
1285
1286    #[inline]
1287    pub const fn is_binomial_beta_logistic(&self) -> bool {
1288        matches!(self, Self::BinomialBetaLogistic(_))
1289    }
1290
1291    /// Coarse kind-level Firth eligibility: every binomial inverse link this
1292    /// enum can represent (Logit/Probit/CLogLog and the stateful
1293    /// LatentCLogLog/SAS/Beta-Logistic/Mixture links) carries a Fisher-weight
1294    /// jet, so kind-level Firth support is exactly binomial membership.
1295    ///
1296    /// The authoritative, link-resolved gate is
1297    /// [`LikelihoodSpec::supports_firth`], which routes through
1298    /// `inverse_link_has_fisher_weight_jet`. Keep this in agreement with that
1299    /// predicate: a future binomial link without a Fisher-weight jet would make
1300    /// this approximation diverge and must be handled at both sites.
1301    #[inline]
1302    pub const fn supports_firth(&self) -> bool {
1303        self.is_binomial()
1304    }
1305}
1306
1307impl LikelihoodSpec {
1308    /// Unchecked constructor: assembles a `(response, link)` cell *without*
1309    /// validating the legal matrix. Reserved for the in-crate named const
1310    /// constructors below (`gaussian_identity`, `poisson_log`, `beta_logit`,
1311    /// the `binomial_*` family, …), every one of which builds a cell that is
1312    /// legal by construction. The public, fallible entry point for an arbitrary
1313    /// `(response, link)` pair is [`LikelihoodSpec::try_new`]; the serde path
1314    /// also validates via [`LikelihoodSpecWire`]. Do not expose illegal cells
1315    /// through this method.
1316    #[inline]
1317    pub const fn new(response: ResponseFamily, link: InverseLink) -> Self {
1318        Self { response, link }
1319    }
1320
1321    /// Returns `true` when the `(response, link)` pair is one of the legal cells
1322    /// the family math honours — exactly the cells enumerated by
1323    /// [`LikelihoodSpec::kind`] before any masking. Each non-binomial response
1324    /// is pinned to a single inverse link; the binomial family admits its full
1325    /// set of probability links but never the identity/log standard links.
1326    #[inline]
1327    pub fn is_legal_cell(response: &ResponseFamily, link: &InverseLink) -> bool {
1328        match response {
1329            // Pure-identity families.
1330            ResponseFamily::Gaussian | ResponseFamily::RoystonParmar => {
1331                matches!(link, InverseLink::Standard(StandardLink::Identity))
1332            }
1333            // Log-link families.
1334            ResponseFamily::Poisson
1335            | ResponseFamily::Gamma
1336            | ResponseFamily::Tweedie { .. }
1337            | ResponseFamily::NegativeBinomial { .. } => {
1338                matches!(link, InverseLink::Standard(StandardLink::Log))
1339            }
1340            // Logit-link family.
1341            ResponseFamily::Beta { .. } => {
1342                matches!(link, InverseLink::Standard(StandardLink::Logit))
1343            }
1344            // Binomial admits every probability link except the inert
1345            // identity/log standard links.
1346            ResponseFamily::Binomial => match link {
1347                InverseLink::Standard(
1348                    StandardLink::Logit | StandardLink::Probit | StandardLink::CLogLog,
1349                ) => true,
1350                InverseLink::Standard(StandardLink::Identity | StandardLink::Log) => false,
1351                InverseLink::LatentCLogLog(_)
1352                | InverseLink::Sas(_)
1353                | InverseLink::BetaLogistic(_)
1354                | InverseLink::Mixture(_) => true,
1355            },
1356        }
1357    }
1358
1359    /// Fallible constructor over an arbitrary `(response, link)` pair. Validates
1360    /// the legal matrix ([`LikelihoodSpec::is_legal_cell`]) so that an illegal
1361    /// cell — one whose stored link would drive a wrong response transformation
1362    /// — is rejected instead of silently masked by [`LikelihoodSpec::kind`].
1363    #[inline]
1364    pub fn try_new(
1365        response: ResponseFamily,
1366        link: InverseLink,
1367    ) -> Result<Self, IllegalLikelihoodCell> {
1368        if Self::is_legal_cell(&response, &link) {
1369            Ok(Self::new(response, link))
1370        } else {
1371            Err(IllegalLikelihoodCell {
1372                response: response.name(),
1373                link: link.link_function().name(),
1374            })
1375        }
1376    }
1377
1378    #[inline]
1379    pub const fn gaussian_identity() -> Self {
1380        Self::new(
1381            ResponseFamily::Gaussian,
1382            InverseLink::Standard(StandardLink::Identity),
1383        )
1384    }
1385
1386    #[inline]
1387    pub const fn binomial_logit() -> Self {
1388        Self::new(
1389            ResponseFamily::Binomial,
1390            InverseLink::Standard(StandardLink::Logit),
1391        )
1392    }
1393
1394    #[inline]
1395    pub const fn binomial_probit() -> Self {
1396        Self::new(
1397            ResponseFamily::Binomial,
1398            InverseLink::Standard(StandardLink::Probit),
1399        )
1400    }
1401
1402    #[inline]
1403    pub const fn binomial_cloglog() -> Self {
1404        Self::new(
1405            ResponseFamily::Binomial,
1406            InverseLink::Standard(StandardLink::CLogLog),
1407        )
1408    }
1409
1410    #[inline]
1411    pub const fn binomial_latent_cloglog(state: LatentCLogLogState) -> Self {
1412        Self::new(ResponseFamily::Binomial, InverseLink::LatentCLogLog(state))
1413    }
1414
1415    #[inline]
1416    pub const fn binomial_sas(state: SasLinkState) -> Self {
1417        Self::new(ResponseFamily::Binomial, InverseLink::Sas(state))
1418    }
1419
1420    #[inline]
1421    pub const fn binomial_beta_logistic(state: SasLinkState) -> Self {
1422        Self::new(ResponseFamily::Binomial, InverseLink::BetaLogistic(state))
1423    }
1424
1425    #[inline]
1426    pub fn binomial_mixture(state: MixtureLinkState) -> Self {
1427        Self::new(ResponseFamily::Binomial, InverseLink::Mixture(state))
1428    }
1429
1430    #[inline]
1431    pub const fn poisson_log() -> Self {
1432        Self::new(
1433            ResponseFamily::Poisson,
1434            InverseLink::Standard(StandardLink::Log),
1435        )
1436    }
1437
1438    #[inline]
1439    pub const fn tweedie_log(p: f64) -> Self {
1440        Self::new(
1441            ResponseFamily::Tweedie { p },
1442            InverseLink::Standard(StandardLink::Log),
1443        )
1444    }
1445
1446    /// Estimated-theta NB spec: `theta` is the seed, refined by the inner
1447    /// solver (#802 default).
1448    #[inline]
1449    pub const fn negative_binomial_log(theta: f64) -> Self {
1450        Self::new(
1451            ResponseFamily::NegativeBinomial {
1452                theta,
1453                theta_fixed: false,
1454            },
1455            InverseLink::Standard(StandardLink::Log),
1456        )
1457    }
1458
1459    /// Fixed-theta NB spec: the fit holds `theta` at exactly this value
1460    /// (`--negative-binomial-theta`, issue #983).
1461    #[inline]
1462    pub const fn negative_binomial_log_fixed(theta: f64) -> Self {
1463        Self::new(
1464            ResponseFamily::NegativeBinomial {
1465                theta,
1466                theta_fixed: true,
1467            },
1468            InverseLink::Standard(StandardLink::Log),
1469        )
1470    }
1471
1472    #[inline]
1473    pub const fn beta_logit(phi: f64) -> Self {
1474        Self::new(
1475            ResponseFamily::Beta { phi },
1476            InverseLink::Standard(StandardLink::Logit),
1477        )
1478    }
1479
1480    #[inline]
1481    pub const fn gamma_log() -> Self {
1482        Self::new(
1483            ResponseFamily::Gamma,
1484            InverseLink::Standard(StandardLink::Log),
1485        )
1486    }
1487
1488    #[inline]
1489    pub const fn royston_parmar() -> Self {
1490        Self::new(
1491            ResponseFamily::RoystonParmar,
1492            InverseLink::Standard(StandardLink::Identity),
1493        )
1494    }
1495
1496    #[inline]
1497    pub const fn link_function(&self) -> LinkFunction {
1498        self.link.link_function()
1499    }
1500
1501    /// Once-and-for-all classification into the legal-only `FamilySpecKind`.
1502    ///
1503    /// `(ResponseFamily, InverseLink)` is a 40-cell product (8 response × 5
1504    /// inverse-link); only the cells listed here are legal. Construction
1505    /// ([`LikelihoodSpec::try_new`]) and deserialization (the
1506    /// [`LikelihoodSpecWire`] `try_from`) both enforce
1507    /// [`LikelihoodSpec::is_legal_cell`], so an illegal cell can never reach
1508    /// this method. Each link-pinned family therefore matches its *one* legal
1509    /// link explicitly; the remaining (now-unreachable) illegal combinations
1510    /// are `unreachable!()` so the historical silent masking — collapsing e.g.
1511    /// `Poisson + Identity` to `PoissonLog` while the transform predicted
1512    /// `μ = η` — can never silently happen again.
1513    pub fn kind(&self) -> FamilySpecKind {
1514        // `legal_cell_kind` returns `Some` for every legal cell and `None`
1515        // for the (by-construction-unreachable) illegal ones. Construction
1516        // (`try_new`) and deserialization (`LikelihoodSpecWire` try_from)
1517        // both enforce `is_legal_cell`, so the `None` branch can never fire
1518        // on a value that exists — `.expect` is the idiomatic loud-on-
1519        // impossible-state assertion (a banned `unreachable!`/`panic!` macro
1520        // would be the same panic with worse provenance). If it ever does
1521        // fire, the message names the offending cell so the silent-masking
1522        // regression this guards against (e.g. `Poisson + Identity`
1523        // collapsing to `PoissonLog`) stays impossible.
1524        self.legal_cell_kind().expect(
1525            "illegal likelihood cell reached kind(): construction (try_new) and \
1526             deserialization (LikelihoodSpecWire) guarantee legality",
1527        )
1528    }
1529
1530    fn legal_cell_kind(&self) -> Option<FamilySpecKind> {
1531        Some(match (&self.response, &self.link) {
1532            (ResponseFamily::Gaussian, InverseLink::Standard(StandardLink::Identity)) => {
1533                FamilySpecKind::GaussianIdentity
1534            }
1535            (ResponseFamily::RoystonParmar, InverseLink::Standard(StandardLink::Identity)) => {
1536                FamilySpecKind::RoystonParmar
1537            }
1538            (ResponseFamily::Poisson, InverseLink::Standard(StandardLink::Log)) => {
1539                FamilySpecKind::PoissonLog
1540            }
1541            (ResponseFamily::Gamma, InverseLink::Standard(StandardLink::Log)) => {
1542                FamilySpecKind::GammaLog
1543            }
1544            (ResponseFamily::Tweedie { p }, InverseLink::Standard(StandardLink::Log)) => {
1545                FamilySpecKind::TweedieLog { p: *p }
1546            }
1547            (
1548                ResponseFamily::NegativeBinomial { theta, .. },
1549                InverseLink::Standard(StandardLink::Log),
1550            ) => FamilySpecKind::NegativeBinomialLog { theta: *theta },
1551            (ResponseFamily::Beta { phi }, InverseLink::Standard(StandardLink::Logit)) => {
1552                FamilySpecKind::BetaLogit { phi: *phi }
1553            }
1554            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Logit)) => {
1555                FamilySpecKind::BinomialLogit
1556            }
1557            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Probit)) => {
1558                FamilySpecKind::BinomialProbit
1559            }
1560            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::CLogLog)) => {
1561                FamilySpecKind::BinomialCLogLog
1562            }
1563            (ResponseFamily::Binomial, InverseLink::LatentCLogLog(state)) => {
1564                FamilySpecKind::BinomialLatentCLogLog(*state)
1565            }
1566            (ResponseFamily::Binomial, InverseLink::Sas(state)) => {
1567                FamilySpecKind::BinomialSas(*state)
1568            }
1569            (ResponseFamily::Binomial, InverseLink::BetaLogistic(state)) => {
1570                FamilySpecKind::BinomialBetaLogistic(*state)
1571            }
1572            (ResponseFamily::Binomial, InverseLink::Mixture(state)) => {
1573                FamilySpecKind::BinomialMixture(state.clone())
1574            }
1575            // Every remaining product cell is illegal. `try_new` /
1576            // `LikelihoodSpecWire::try_from` reject these, so construction and
1577            // deserialization guarantee they are unreachable here; `None`
1578            // surfaces that to `kind()`, which aborts loudly via `.expect`
1579            // rather than misclassify the family (a wrong `FamilySpecKind`
1580            // would silently corrupt every downstream likelihood/gradient
1581            // evaluation). A banned `panic!`/`unreachable!` macro would be the
1582            // same divergence with worse provenance.
1583            _ => return None,
1584        })
1585    }
1586
1587    #[inline]
1588    pub fn is_binomial(&self) -> bool {
1589        self.kind().is_binomial()
1590    }
1591
1592    #[inline]
1593    pub fn is_gaussian_identity(&self) -> bool {
1594        self.kind().is_gaussian_identity()
1595    }
1596
1597    #[inline]
1598    pub fn is_royston_parmar(&self) -> bool {
1599        self.kind().is_royston_parmar()
1600    }
1601
1602    #[inline]
1603    pub fn is_latent_cloglog(&self) -> bool {
1604        self.kind().is_latent_cloglog()
1605    }
1606
1607    #[inline]
1608    pub fn is_binomial_mixture(&self) -> bool {
1609        self.kind().is_binomial_mixture()
1610    }
1611
1612    #[inline]
1613    pub fn is_binomial_sas(&self) -> bool {
1614        self.kind().is_binomial_sas()
1615    }
1616
1617    #[inline]
1618    pub fn is_binomial_beta_logistic(&self) -> bool {
1619        self.kind().is_binomial_beta_logistic()
1620    }
1621
1622    /// Default scale metadata for this (response, link).
1623    #[inline]
1624    pub fn default_scale_metadata(&self) -> LikelihoodScaleMetadata {
1625        match &self.response {
1626            ResponseFamily::Gaussian => LikelihoodScaleMetadata::ProfiledGaussian,
1627            ResponseFamily::Gamma => LikelihoodScaleMetadata::EstimatedGammaShape { shape: 1.0 },
1628            // Binomial and Poisson have `phi ≡ 1` (variance fully pinned by the
1629            // mean), so a fixed unit dispersion is correct.
1630            ResponseFamily::Binomial | ResponseFamily::Poisson => {
1631                LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 }
1632            }
1633            // Negative-Binomial's overdispersion `theta` (`Var(y)=mu+mu^2/theta`)
1634            // is a genuine free parameter estimated jointly with the mean by
1635            // default — the family-variant `theta` is only the seed, refined from
1636            // the converged-η ML score during fitting, exactly like the Gamma
1637            // shape / Beta precision / Tweedie φ. Freezing it at the seed made
1638            // every variance-derived output (coefficient/η SEs, Wald and credible
1639            // intervals, predictive intervals, `generate` draws) ignore the
1640            // data's overdispersion (issue #802). `phi` itself stays `≡ 1`.
1641            //
1642            // A user-supplied `--negative-binomial-theta` is the opposite
1643            // contract (issue #983): `theta_fixed = true` routes to the
1644            // non-estimated scale variant, so the inner solver's refresh gate
1645            // (`negbin_theta_is_estimated()`) stays closed and the fit honours
1646            // the held value everywhere it enters.
1647            ResponseFamily::NegativeBinomial { theta, theta_fixed } => {
1648                if *theta_fixed {
1649                    LikelihoodScaleMetadata::FixedNegBinTheta { theta: *theta }
1650                } else {
1651                    LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: *theta }
1652                }
1653            }
1654            // Tweedie's dispersion `phi` is a genuine free parameter
1655            // (`Var(y) = phi · mu^p`) and is estimated jointly with the mean by
1656            // default, exactly like the Gamma shape and Beta precision. The seed
1657            // `phi = 1` is refined from the converged-η Pearson residuals during
1658            // fitting (issue #771). Freezing it at 1 made every variance-derived
1659            // output (SEs, intervals, generate draws) ignore the data's spread.
1660            ResponseFamily::Tweedie { .. } => {
1661                LikelihoodScaleMetadata::EstimatedTweediePhi { phi: 1.0 }
1662            }
1663            // Beta precision is estimated jointly with the mean by default
1664            // (magic-by-default, issue #567): the family-variant `phi` is the
1665            // seed, refined from the working residuals during fitting.
1666            ResponseFamily::Beta { phi } => LikelihoodScaleMetadata::EstimatedBetaPhi { phi: *phi },
1667            ResponseFamily::RoystonParmar => LikelihoodScaleMetadata::Unspecified,
1668        }
1669    }
1670
1671    /// Human-readable label, routed through `FamilySpecKind`.
1672    #[inline]
1673    pub fn pretty_name(&self) -> &'static str {
1674        self.kind().pretty_name()
1675    }
1676
1677    /// Short identifier, routed through `FamilySpecKind`.
1678    #[inline]
1679    pub fn name(&self) -> &'static str {
1680        self.kind().name()
1681    }
1682
1683    #[inline]
1684    pub fn supports_firth(&self) -> bool {
1685        matches!(self.response, ResponseFamily::Binomial)
1686            && inverse_link_has_fisher_weight_jet(&self.link)
1687    }
1688
1689    /// Family-level fixed-dispersion contract. Returns the dispersion parameter
1690    /// `phi` that the GLM log-likelihood / weight expressions treat as fixed
1691    /// for the given `ResponseFamily`, or `None` when the family carries no
1692    /// fixed scale (profiled or jointly estimated).
1693    ///
1694    /// - `Gaussian` and `Gamma` profile/estimate the scale jointly with the
1695    ///   mean, so no fixed `phi` is exposed here.
1696    /// - `Binomial` and `Poisson` are unit-scale exponential-family fits, so the
1697    ///   contract is `Some(1.0)`. NegativeBinomial's overdispersion lives in
1698    ///   `theta` (a separate parameter / flag), not in a free `phi`, so it also
1699    ///   returns `Some(1.0)`.
1700    /// - `Tweedie { p }` carries its variance power on the family variant. Its
1701    ///   free dispersion `phi` lives in `LikelihoodScaleMetadata` and is
1702    ///   estimated by default (`EstimatedTweediePhi`, issue #771), so this
1703    ///   family-level contract only exposes the unit seed used when callers ask
1704    ///   the response family without scale metadata.
1705    /// - `Beta { phi }` carries its precision parameter directly on the family
1706    ///   variant; the contract returns that exact value rather than the
1707    ///   placeholder used elsewhere for unit-scale GLMs.
1708    /// - `RoystonParmar` has no GLM-style dispersion slot.
1709    #[inline]
1710    pub const fn fixed_dispersion(&self) -> Option<f64> {
1711        match self.response {
1712            ResponseFamily::Gaussian | ResponseFamily::Gamma | ResponseFamily::RoystonParmar => {
1713                None
1714            }
1715            ResponseFamily::Binomial
1716            | ResponseFamily::Poisson
1717            | ResponseFamily::Tweedie { .. }
1718            | ResponseFamily::NegativeBinomial { .. } => Some(1.0),
1719            ResponseFamily::Beta { phi } => Some(phi),
1720        }
1721    }
1722}
1723
1724#[inline]
1725pub const fn is_valid_tweedie_power(p: f64) -> bool {
1726    p.is_finite() && p > 1.0 && p < 2.0
1727}
1728
1729/// Error returned when an `InverseLink` cannot be paired with a particular
1730/// response family because the link is structurally unsupported for that
1731/// family. Carries the link name so call sites can produce a useful message
1732/// without losing the offending variant.
1733#[derive(Debug, Clone, PartialEq, Eq)]
1734pub struct UnsupportedLinkError {
1735    pub family: &'static str,
1736    pub link_name: String,
1737}
1738
1739impl UnsupportedLinkError {
1740    /// Construct an `UnsupportedLinkError` tagged with the response-family
1741    /// name (`"binomial"`, `"gaussian"`, ...) and a printable name for the
1742    /// offending `InverseLink` variant (extracted via the module-private
1743    /// `inverse_link_diagnostic_name`). No allocation beyond the link name.
1744    #[inline]
1745    pub fn new(family: &'static str, link: &InverseLink) -> Self {
1746        Self {
1747            family,
1748            link_name: inverse_link_diagnostic_name(link),
1749        }
1750    }
1751}
1752
1753impl std::fmt::Display for UnsupportedLinkError {
1754    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1755        write!(
1756            f,
1757            "inverse link `{}` is not supported by the {} response family",
1758            self.link_name, self.family
1759        )
1760    }
1761}
1762
1763impl std::error::Error for UnsupportedLinkError {}
1764
1765#[inline]
1766pub fn inverse_link_diagnostic_name(link: &InverseLink) -> String {
1767    match link {
1768        InverseLink::Standard(lf) => lf.name().to_string(),
1769        InverseLink::LatentCLogLog(_) => "latent-cloglog".to_string(),
1770        InverseLink::Sas(_) => "sas".to_string(),
1771        InverseLink::BetaLogistic(_) => "beta-logistic".to_string(),
1772        InverseLink::Mixture(_) => "mixture".to_string(),
1773    }
1774}
1775
1776/// Resolve a binomial-flavoured `LikelihoodSpec` from an `InverseLink`.
1777///
1778/// `StandardLink::Logit | Probit | CLogLog` and the state-bearing
1779/// `LatentCLogLog / Sas / BetaLogistic / Mixture` variants are accepted as
1780/// binomial-compatible. `StandardLink::Log | Identity` have no canonical
1781/// binomial meaning and return `UnsupportedLinkError`. Since
1782/// `InverseLink::Standard` carries `StandardLink` (not `LinkFunction`), the
1783/// previously-required `Standard(LinkFunction::Sas | BetaLogistic)` arm is
1784/// structurally impossible and has been removed.
1785#[inline]
1786pub fn inverse_link_to_binomial_spec(
1787    link: &InverseLink,
1788) -> Result<LikelihoodSpec, UnsupportedLinkError> {
1789    match link {
1790        InverseLink::Standard(StandardLink::Logit)
1791        | InverseLink::Standard(StandardLink::Probit)
1792        | InverseLink::Standard(StandardLink::CLogLog) => {
1793            Ok(LikelihoodSpec::new(ResponseFamily::Binomial, link.clone()))
1794        }
1795        InverseLink::LatentCLogLog(_)
1796        | InverseLink::Sas(_)
1797        | InverseLink::BetaLogistic(_)
1798        | InverseLink::Mixture(_) => {
1799            Ok(LikelihoodSpec::new(ResponseFamily::Binomial, link.clone()))
1800        }
1801        InverseLink::Standard(StandardLink::Log)
1802        | InverseLink::Standard(StandardLink::Identity) => {
1803            Err(UnsupportedLinkError::new("binomial", link))
1804        }
1805    }
1806}
1807
1808/// How a likelihood's scale parameter is handled by the fit/result contract.
1809#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1810pub enum LikelihoodScaleMetadata {
1811    /// Gaussian identity fits profile sigma outside the fixed-scale GLM machinery.
1812    ProfiledGaussian,
1813    /// Fixed exponential-dispersion parameter `phi`.
1814    FixedDispersion { phi: f64 },
1815    /// Fixed Gamma shape `k`, equivalent to `phi = 1 / k`.
1816    FixedGammaShape { shape: f64 },
1817    /// Gamma shape `k` estimated jointly with the mean model.
1818    EstimatedGammaShape { shape: f64 },
1819    /// Beta-regression precision `phi` estimated jointly with the mean model.
1820    /// `Var(y) = mu(1-mu)/(1+phi)`; larger `phi` means less noise. Estimated
1821    /// from the working residuals after each mean fit and refreshed across outer
1822    /// iterations, exactly like the Gamma shape (issue #567).
1823    EstimatedBetaPhi { phi: f64 },
1824    /// Tweedie exponential-dispersion `phi` estimated jointly with the mean
1825    /// model. `Var(y) = phi · mu^p` with `phi` a genuine free parameter (unlike
1826    /// Binomial/Poisson, where `phi ≡ 1`). Estimated by the Pearson moment
1827    /// estimator `phî = Σ wᵢ (yᵢ − μᵢ)² / μᵢ^p / Σ wᵢ` at the converged η and
1828    /// refreshed across outer iterations, exactly like the Gamma shape and the
1829    /// Beta precision. `phi` enters the IRLS working weight `prior·μ^{2−p}/phi`,
1830    /// so the coefficient covariance `Vb = H⁻¹` already scales as `phi` and the
1831    /// reported SEs track `√phi` (issue #771).
1832    EstimatedTweediePhi { phi: f64 },
1833    /// Negative-Binomial overdispersion `theta` estimated jointly with the mean
1834    /// model. `Var(y) = mu + mu^2 / theta`; larger `theta` means less
1835    /// overdispersion (the Poisson limit is `theta → ∞`). Estimated by the
1836    /// maximum-likelihood `theta` score
1837    /// `Σ wᵢ[ψ(yᵢ+θ) − ψ(θ) + lnθ + 1 − ln(θ+μᵢ) − (yᵢ+θ)/(μᵢ+θ)] = 0` at the
1838    /// converged η (MASS `glm.nb`'s `theta.ml`) and refreshed across outer
1839    /// iterations, exactly like the Gamma shape / Beta precision / Tweedie φ.
1840    /// Unlike those, `theta` is *not* a dispersion scale `phi`: it enters only
1841    /// the IRLS working weight `W = μθ/(θ+μ)` (the full NB2 Fisher information),
1842    /// so the stored penalized Hessian is already the true one and the
1843    /// coefficient covariance `Vb = H⁻¹` takes no post-hoc multiply — `phi ≡ 1`
1844    /// for NB, the overdispersion lives in the variance function. The `theta`
1845    /// carried here mirrors `ResponseFamily::NegativeBinomial { theta }` (the
1846    /// canonical store every weight/deviance expression reads), kept in sync by
1847    /// `with_negbin_theta`, exactly as `EstimatedBetaPhi` mirrors `Beta { phi }`
1848    /// (issue #802).
1849    EstimatedNegBinTheta { theta: f64 },
1850    /// Negative-Binomial overdispersion `theta` held fixed at a user-supplied
1851    /// value (`--negative-binomial-theta`, issue #983). Identical role to
1852    /// `EstimatedNegBinTheta` in every weight / variance / covariance
1853    /// expression (`W = μθ/(θ+μ)`, `Var(y) = μ + μ²/θ`, `phi ≡ 1`), but the
1854    /// inner solver's ML refresh is gated off: the recorded `theta` is the
1855    /// user's, by construction. The fixed/estimated split mirrors
1856    /// `FixedGammaShape` vs `EstimatedGammaShape`.
1857    FixedNegBinTheta { theta: f64 },
1858    /// The engine does not expose fixed-scale semantics for this family.
1859    Unspecified,
1860}
1861
1862impl LikelihoodScaleMetadata {
1863    #[inline]
1864    pub const fn fixed_phi(self) -> Option<f64> {
1865        match self {
1866            Self::FixedDispersion { phi }
1867            | Self::EstimatedBetaPhi { phi }
1868            | Self::EstimatedTweediePhi { phi } => Some(phi),
1869            Self::FixedGammaShape { shape } | Self::EstimatedGammaShape { shape } => {
1870                Some(1.0 / shape)
1871            }
1872            // NB's dispersion scale is `phi ≡ 1` (the overdispersion is carried
1873            // by `theta` inside the variance function, not a scale multiply), so
1874            // the fixed-`phi` contract is `Some(1.0)` — NOT `theta`.
1875            Self::EstimatedNegBinTheta { .. } | Self::FixedNegBinTheta { .. } => Some(1.0),
1876            Self::ProfiledGaussian | Self::Unspecified => None,
1877        }
1878    }
1879
1880    /// Whether the Negative-Binomial overdispersion `theta` is estimated from
1881    /// data (the default for NB families, issue #802).
1882    #[inline]
1883    pub const fn negbin_theta_is_estimated(self) -> bool {
1884        matches!(self, Self::EstimatedNegBinTheta { .. })
1885    }
1886
1887    /// The Negative-Binomial `theta` carried in the scale metadata (estimated
1888    /// or user-fixed), or `None` for non-NB families.
1889    #[inline]
1890    pub const fn negbin_theta(self) -> Option<f64> {
1891        match self {
1892            Self::EstimatedNegBinTheta { theta } | Self::FixedNegBinTheta { theta } => Some(theta),
1893            _ => None,
1894        }
1895    }
1896
1897    /// Whether the Beta-regression precision `phi` is estimated from data.
1898    #[inline]
1899    pub const fn beta_phi_is_estimated(self) -> bool {
1900        matches!(self, Self::EstimatedBetaPhi { .. })
1901    }
1902
1903    /// Whether the Tweedie exponential-dispersion `phi` is estimated from data.
1904    #[inline]
1905    pub const fn tweedie_phi_is_estimated(self) -> bool {
1906        matches!(self, Self::EstimatedTweediePhi { .. })
1907    }
1908
1909    #[inline]
1910    pub const fn gamma_shape(self) -> Option<f64> {
1911        match self {
1912            Self::FixedGammaShape { shape } | Self::EstimatedGammaShape { shape } => Some(shape),
1913            _ => None,
1914        }
1915    }
1916
1917    #[inline]
1918    pub const fn gamma_shape_is_estimated(self) -> bool {
1919        matches!(self, Self::EstimatedGammaShape { .. })
1920    }
1921}
1922
1923/// Whether a stored log-likelihood includes response-only normalization constants.
1924#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1925pub enum LogLikelihoodNormalization {
1926    Full,
1927    OmittingResponseConstants,
1928    UserProvided,
1929}
1930
1931/// Explicit GLM likelihood specification: response/link spec plus scale semantics.
1932///
1933/// `spec` is the canonical `(ResponseFamily, InverseLink)` selector. `scale`
1934/// records how the scale parameter is handled (profiled Gaussian sigma, fixed
1935/// dispersion, fixed/estimated Gamma shape). The Gamma shape is mutated in
1936/// place during PIRLS via `with_gamma_shape`; preserving that field on this
1937/// struct is what lets the inner solver thread the estimated shape into
1938/// deviance / log-likelihood / weight evaluation without a separate side
1939/// channel.
1940#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1941pub struct GlmLikelihoodSpec {
1942    pub spec: LikelihoodSpec,
1943    pub scale: LikelihoodScaleMetadata,
1944}
1945
1946impl GlmLikelihoodSpec {
1947    /// Build a `GlmLikelihoodSpec` from a `LikelihoodSpec`, deriving the
1948    /// canonical default scale metadata for the response family.
1949    #[inline]
1950    pub fn canonical(spec: LikelihoodSpec) -> Self {
1951        let scale = spec.default_scale_metadata();
1952        Self { spec, scale }
1953    }
1954
1955    #[inline]
1956    pub fn link_function(&self) -> LinkFunction {
1957        self.spec.link_function()
1958    }
1959
1960    #[inline]
1961    pub fn fixed_phi(&self) -> Option<f64> {
1962        self.scale.fixed_phi()
1963    }
1964
1965    /// Multiplier converting the stored unscaled inverse penalized Hessian
1966    /// `H⁻¹` into the reported coefficient covariance `Vb = H⁻¹ · scale`.
1967    ///
1968    /// # Invariant
1969    ///
1970    /// `Vb` is the inverse of the Hessian of the *actual penalized objective the
1971    /// inner solver minimizes*. The stored Hessian is always assembled as
1972    /// `H = XᵀWX + S_λ`, with the penalty `S_λ` added **unscaled** (see
1973    /// `pirls::penalty::add_to_hessian`). Whether `H` is already that true
1974    /// objective Hessian — and hence whether any post-hoc dispersion multiply is
1975    /// warranted — is decided entirely by what the IRLS working weight `W`
1976    /// carries:
1977    ///
1978    /// * **Working weight already carries the reciprocal dispersion / full
1979    ///   Fisher information.** Then `H = Xᵀ(W_sf/φ)X + S_λ` already equals the
1980    ///   true penalized Hessian (e.g. mgcv's `XᵀW_sfX/φ + S_λ` for Gamma), so
1981    ///   `Vb = H⁻¹` and the scale is exactly `1.0`. This is the case for Gamma
1982    ///   (`W = prior·shape = prior/φ`), Tweedie (`W = prior·μ^{2−p}/φ`), Beta
1983    ///   and Negative-Binomial (the working weight is the complete fixed-scale
1984    ///   Fisher information), and the fixed-scale exponential families
1985    ///   Poisson/Binomial (`φ ≡ 1`). Multiplying `H⁻¹` by the dispersion again
1986    ///   for any of these double-counts it and shrinks every SE by `√dispersion`.
1987    ///
1988    /// * **Working weight is scale-free** (`W = priorweights`, the profiled
1989    ///   Gaussian convention). Then the data term carries an implicit unit scale
1990    ///   and `H = XᵀPX + S_λ` is the Hessian of `½·(scaled deviance)·σ²⁻¹`
1991    ///   *without* the `σ²`. The correct covariance restores it:
1992    ///   `Vb = H⁻¹ · σ̂²`. Only this branch returns a non-unit scale.
1993    ///
1994    /// `profiled_gaussian_phi` is the profiled residual variance `σ̂²` and is
1995    /// consulted **only** for the scale-free profiled-Gaussian branch; every
1996    /// other family ignores it. This deliberately does NOT touch
1997    /// `dispersion()` / `dispersion_from_likelihood`, which still report the
1998    /// response-level observation noise (`1/shape` for Gamma, `1/(1+φ)` for
1999    /// Beta, …) used by predictive-interval construction — a distinct quantity
2000    /// from the coefficient-covariance scale defined here.
2001    #[inline]
2002    pub fn coefficient_covariance_scale(&self, profiled_gaussian_phi: f64) -> f64 {
2003        match self.scale {
2004            // Scale-free working weight: restore the profiled variance.
2005            LikelihoodScaleMetadata::ProfiledGaussian => profiled_gaussian_phi,
2006            // Working weight already carries the dispersion / full Fisher
2007            // information, so the stored H is the true penalized Hessian and no
2008            // further dispersion multiply is warranted.
2009            //
2010            // FixedDispersion covers the explicitly-scaled Gaussian submodel
2011            // (W·=1/φ above) and Negative-Binomial; the Gamma, Beta and Tweedie
2012            // variants fold their reciprocal-dispersion / precision / φ into W
2013            // (Tweedie W = prior·μ^{2−p}/φ, so the SE already scales as √φ); and
2014            // Unspecified families never expose a separate post-hoc scale.
2015            LikelihoodScaleMetadata::FixedDispersion { .. }
2016            | LikelihoodScaleMetadata::FixedGammaShape { .. }
2017            | LikelihoodScaleMetadata::EstimatedGammaShape { .. }
2018            | LikelihoodScaleMetadata::EstimatedBetaPhi { .. }
2019            | LikelihoodScaleMetadata::EstimatedTweediePhi { .. }
2020            // Negative-Binomial folds `theta` into the working weight
2021            // `W = μθ/(θ+μ)` (the full NB2 Fisher information), so the stored
2022            // `H = XᵀWX + S_λ` is already the true penalized Hessian and the
2023            // covariance scale is `1.0` (`phi ≡ 1`). The reported SEs respond to
2024            // the data's overdispersion entirely through that `theta`-dependent
2025            // weight (issue #802) — multiplying again would double-count it.
2026            // The same holds verbatim for a user-fixed `theta` (issue #983).
2027            | LikelihoodScaleMetadata::EstimatedNegBinTheta { .. }
2028            | LikelihoodScaleMetadata::FixedNegBinTheta { .. }
2029            | LikelihoodScaleMetadata::Unspecified => 1.0,
2030        }
2031    }
2032
2033    #[inline]
2034    pub fn gamma_shape(&self) -> Option<f64> {
2035        self.scale.gamma_shape()
2036    }
2037
2038    /// Mutate the Gamma shape parameter in place while preserving the rest of
2039    /// the spec. The shape only takes effect for Gamma families; for other
2040    /// families the scale metadata is left untouched.
2041    #[inline]
2042    pub fn with_gamma_shape(mut self, shape: f64) -> Self {
2043        self.scale = match self.scale {
2044            LikelihoodScaleMetadata::FixedGammaShape { .. } => {
2045                LikelihoodScaleMetadata::FixedGammaShape { shape }
2046            }
2047            LikelihoodScaleMetadata::EstimatedGammaShape { .. } => {
2048                LikelihoodScaleMetadata::EstimatedGammaShape { shape }
2049            }
2050            other => match &self.spec.response {
2051                ResponseFamily::Gamma => LikelihoodScaleMetadata::EstimatedGammaShape { shape },
2052                _ => other,
2053            },
2054        };
2055        self
2056    }
2057
2058    /// Whether the Beta-regression precision `phi` is estimated from data.
2059    #[inline]
2060    pub fn beta_phi_is_estimated(&self) -> bool {
2061        self.scale.beta_phi_is_estimated()
2062    }
2063
2064    /// Mutate the Beta precision `phi` in place, on BOTH the family variant
2065    /// (where every PIRLS weight / deviance / log-likelihood expression reads it
2066    /// via `ResponseFamily::Beta { phi }`) and the scale metadata (the
2067    /// estimated-vs-fixed contract). No-op for non-Beta families. The inner
2068    /// solver calls this once per inner solve after a moment estimate of `phi`
2069    /// from the working residuals, so the IRLS weights `Var(y)=mu(1-mu)/(1+phi)`
2070    /// reflect the true precision rather than the `phi=1` seed (issue #567).
2071    #[inline]
2072    pub fn with_beta_phi(mut self, phi: f64) -> Self {
2073        if let ResponseFamily::Beta { phi: family_phi } = &mut self.spec.response {
2074            *family_phi = phi;
2075            self.scale = LikelihoodScaleMetadata::EstimatedBetaPhi { phi };
2076        }
2077        self
2078    }
2079
2080    /// Whether the Tweedie exponential-dispersion `phi` is estimated from data.
2081    #[inline]
2082    pub fn tweedie_phi_is_estimated(&self) -> bool {
2083        self.scale.tweedie_phi_is_estimated()
2084    }
2085
2086    /// Mutate the Tweedie dispersion `phi` in place. Unlike Beta, the Tweedie
2087    /// power `p` (not `phi`) is what is carried on the `ResponseFamily::Tweedie`
2088    /// variant; the dispersion lives purely in the scale metadata and is read by
2089    /// the IRLS weight (`prior·μ^{2−p}/phi`) through `fixed_phi()`. So updating
2090    /// the metadata here is sufficient to thread the estimated `phi` into every
2091    /// weight / covariance expression. No-op for non-Tweedie families (issue
2092    /// #771).
2093    #[inline]
2094    pub fn with_tweedie_phi(mut self, phi: f64) -> Self {
2095        if matches!(self.spec.response, ResponseFamily::Tweedie { .. }) {
2096            self.scale = LikelihoodScaleMetadata::EstimatedTweediePhi { phi };
2097        }
2098        self
2099    }
2100
2101    /// Whether the Negative-Binomial overdispersion `theta` is estimated from
2102    /// data (issue #802).
2103    #[inline]
2104    pub fn negbin_theta_is_estimated(&self) -> bool {
2105        self.scale.negbin_theta_is_estimated()
2106    }
2107
2108    /// Mutate the Negative-Binomial overdispersion `theta` in place, on BOTH the
2109    /// family variant (where every PIRLS weight / deviance / log-likelihood
2110    /// expression reads it via `ResponseFamily::NegativeBinomial { theta }`) and
2111    /// the scale metadata (the estimated-vs-fixed contract). No-op for non-NB
2112    /// families. The inner solver calls this once per inner solve after a
2113    /// maximum-likelihood estimate of `theta` from the working residuals, so the
2114    /// IRLS weight `W = μθ/(θ+μ)` and the variance `Var(y)=mu+mu^2/theta` reflect
2115    /// the data's overdispersion rather than the seed `theta` (issue #802). This
2116    /// mirrors `with_beta_phi` exactly — both keep the family variant and the
2117    /// scale metadata as two synchronized views of one estimated parameter.
2118    /// No-op for a user-fixed `theta` (`theta_fixed = true` /
2119    /// `FixedNegBinTheta`, issue #983): the held value is the contract, and
2120    /// this mutator must never let an estimation path overwrite it — the
2121    /// PIRLS refresh gate (`negbin_theta_is_estimated()`) already skips the
2122    /// call, this enforces the same invariant at the data itself.
2123    #[inline]
2124    pub fn with_negbin_theta(mut self, theta: f64) -> Self {
2125        if let ResponseFamily::NegativeBinomial {
2126            theta: family_theta,
2127            theta_fixed,
2128        } = &mut self.spec.response
2129            && !*theta_fixed
2130        {
2131            *family_theta = theta;
2132            self.scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta };
2133        }
2134        self
2135    }
2136
2137    /// The estimated Negative-Binomial `theta`, read from the family variant
2138    /// (the canonical store), or `None` for non-NB families.
2139    #[inline]
2140    pub fn negbin_theta(&self) -> Option<f64> {
2141        match self.spec.response {
2142            ResponseFamily::NegativeBinomial { theta, .. } => Some(theta),
2143            _ => None,
2144        }
2145    }
2146
2147    /// Produce a copy of this spec with the Tweedie exponential-dispersion
2148    /// `phi` PINNED at `phi` for the duration of the smoothing-parameter (λ)
2149    /// search (#1477). Converts an `EstimatedTweediePhi` scale into the
2150    /// statistically-identical `FixedDispersion` form, which gates off the
2151    /// per-inner-solve Pearson refresh in
2152    /// `GamWorkingModel::update_with_curvature` (its guard is
2153    /// `tweedie_phi_is_estimated()`, which `FixedDispersion` does not satisfy)
2154    /// while leaving every weight / variance / covariance expression unchanged
2155    /// (they read `phi` through `fixed_phi()`, which `FixedDispersion` answers
2156    /// identically).
2157    ///
2158    /// Rationale: with `phi` estimated, the inner solver re-derives it from each
2159    /// outer iterate's *warm-start* η (the Pearson moment estimator
2160    /// `phî = Σ wᵢ(yᵢ−μᵢ)²/μᵢ^p / Σ wᵢ`). The Tweedie LAML omits the
2161    /// `phi`-dependent saddlepoint normalizer `a(y,φ)` from `−ℓ(β̂)` — valid only
2162    /// when `phi` is fixed across the surface — so a drifting `phi` makes
2163    /// `F(ρ)` a non-stationary function of ρ that REWARDS dispersion inflation:
2164    /// driving a double-penalty null-space `λ` up kills a genuinely-supported
2165    /// linear trend, the residuals grow, the warm-start `phî` rises, and the
2166    /// `[yθ−κ]/φ` deviance term shrinks with no compensating normalizer penalty,
2167    /// so the criterion falls and the outer optimizer rails `λ_null` to the box
2168    /// bound (the #1477 Tweedie double-penalty boundary blow-up). Holding `phi`
2169    /// fixed across the λ-search makes `F(ρ) = REML(ρ, φ_frozen)` a genuine
2170    /// stationary function of ρ, exactly as for the Gaussian profiled scale
2171    /// (whose `(n−Mp)/2·log(2πφ̂)` normalizer is retained) and as mgcv does for
2172    /// Tweedie. `phi` is still Pearson-refreshed at the single final reported fit
2173    /// (the `refine_dispersion_at_converged_eta = true` accept-fit). No-op for
2174    /// non-Tweedie families and for a user-fixed `phi`.
2175    #[inline]
2176    pub fn with_tweedie_phi_frozen_for_search(mut self, phi: f64) -> Self {
2177        if matches!(self.spec.response, ResponseFamily::Tweedie { .. })
2178            && self.scale.tweedie_phi_is_estimated()
2179        {
2180            self.scale = LikelihoodScaleMetadata::FixedDispersion { phi };
2181        }
2182        self
2183    }
2184
2185    /// Produce a copy of this spec with the Negative-Binomial overdispersion
2186    /// `theta` PINNED at `theta` for the duration of the smoothing-parameter
2187    /// (λ) search (#1082). Converts an `EstimatedNegBinTheta` spec into the
2188    /// statistically-identical `FixedNegBinTheta` form (`theta_fixed = true`),
2189    /// which gates off the per-inner-solve ML refresh in
2190    /// `GamWorkingModel::update_with_curvature` (its guard is
2191    /// `negbin_theta_is_estimated()`).
2192    ///
2193    /// Rationale: with θ estimated, the inner solver re-derives θ from each
2194    /// outer iterate's *warm-start* η, so θ — and hence the NB working response,
2195    /// deviance and penalty-logdet that feed the REML criterion — drifts every
2196    /// outer evaluation. The outer optimizer then chases a moving target and the
2197    /// projected-gradient convergence test never trips, grinding the loop to
2198    /// `max_iter` (the #1082 negative-binomial tensor timeout). Holding θ fixed
2199    /// across the λ-search makes the REML objective `F(ρ) = REML(ρ, θ_frozen)` a
2200    /// genuine stationary function of ρ, so the loop converges in a handful of
2201    /// iterations — and θ is still ML-refreshed at the single final, reported fit
2202    /// (the `refine_dispersion_at_converged_eta = true` accept-fit), exactly as
2203    /// the function-level docs require ("estimate the scale at the converged fit,
2204    /// not inside the λ search; mgcv likewise"). No-op for non-NB families and
2205    /// for an already user-fixed θ.
2206    #[inline]
2207    pub fn with_negbin_theta_frozen_for_search(mut self, theta: f64) -> Self {
2208        if let ResponseFamily::NegativeBinomial {
2209            theta: family_theta,
2210            theta_fixed,
2211        } = &mut self.spec.response
2212        {
2213            *family_theta = theta;
2214            *theta_fixed = true;
2215            self.scale = LikelihoodScaleMetadata::FixedNegBinTheta { theta };
2216        }
2217        self
2218    }
2219
2220    /// Produce a copy of this spec with the Gamma shape `k = 1/φ` PINNED at
2221    /// `shape` for the duration of the smoothing-parameter (λ) search (#1074).
2222    /// Converts an `EstimatedGammaShape` scale into the statistically-identical
2223    /// `FixedGammaShape` form, which gates off the per-inner-solve shape refresh
2224    /// in `GamWorkingModel::update_with_curvature` (its guard is
2225    /// `gamma_shape_is_estimated()`, which `FixedGammaShape` does not satisfy)
2226    /// while leaving every weight / deviance / log-likelihood expression
2227    /// unchanged (they read the shape through `gamma_shape()` / `fixed_phi()`,
2228    /// which `FixedGammaShape` answers identically).
2229    ///
2230    /// Rationale: with the shape estimated, the inner solver re-derives it from
2231    /// each outer iterate's *warm-start* η (the converged-η MLE
2232    /// `k̂` solving `ln k − ψ(k) = mean[y/μ − ln(y/μ) − 1]`). The Gamma working
2233    /// weight is `W = prior·k` and the omitting-constants log-likelihood is
2234    /// `ℓ(β̂) = −k·½·D(ρ)` (the `k`-dependent saturated normalizer is dropped,
2235    /// #359), so a `k` that swings 2×↔ with the warm-start η makes BOTH the
2236    /// likelihood-curvature `H = k·XᵀX + λS` and the data-fit term `k·½D` jump
2237    /// discontinuously with ρ — the REML criterion `V(ρ)` develops deterministic
2238    /// spikes between the smooth basin floors (e.g. a flat warm-start η at a
2239    /// just-rejected over-smoothed trial gives `k≈2.3`, the fitted-surface η at
2240    /// the neighbor gives `k≈4.7`, doubling `−ℓ` with β̂ essentially unchanged).
2241    /// The analytic outer gradient holds `k` fixed, so it can never agree with
2242    /// the realized cost's `k(ρ)` motion: the projected gradient floors at
2243    /// `O(|∂k/∂ρ|·½D)` and the ARC descent stalls on a weakly-identified valley,
2244    /// railing `λ` to the over-smoothed corner (the #1074 te/Gamma tensor
2245    /// under-recovery). Holding `k` fixed across the λ-search makes
2246    /// `F(ρ) = REML(ρ, k_frozen)` a genuine stationary function of ρ, exactly as
2247    /// the sibling Tweedie-φ (#1477) and NB-θ (#1082) freezes do, and as mgcv
2248    /// does (it fixes the scale across the smoothness search for the scale-free
2249    /// Gamma mean). `k` is still ML-refreshed at the single final reported fit
2250    /// (the `refine_dispersion_at_converged_eta = true` accept-fit), so the
2251    /// reported dispersion / SEs remain the converged-η estimate. No-op for
2252    /// non-Gamma families and for a user-fixed shape.
2253    #[inline]
2254    pub fn with_gamma_shape_frozen_for_search(mut self, shape: f64) -> Self {
2255        if matches!(self.spec.response, ResponseFamily::Gamma)
2256            && self.scale.gamma_shape_is_estimated()
2257        {
2258            self.scale = LikelihoodScaleMetadata::FixedGammaShape { shape };
2259        }
2260        self
2261    }
2262}
2263
2264#[cfg(test)]
2265mod tests {
2266    use super::*;
2267    use ndarray::arr1;
2268
2269    // -----------------------------------------------------------------------
2270    // CoefficientGroupPrior::validate
2271    // -----------------------------------------------------------------------
2272
2273    #[test]
2274    fn prior_flat_always_ok() {
2275        assert!(CoefficientGroupPrior::Flat.validate("ctx").is_ok());
2276    }
2277
2278    #[test]
2279    fn prior_normal_log_precision_valid() {
2280        assert!(
2281            CoefficientGroupPrior::NormalLogPrecision { mean: 0.0, sd: 1.0 }
2282                .validate("ctx")
2283                .is_ok()
2284        );
2285    }
2286
2287    #[test]
2288    fn prior_normal_log_precision_infinite_mean_errors() {
2289        assert!(
2290            CoefficientGroupPrior::NormalLogPrecision { mean: f64::INFINITY, sd: 1.0 }
2291                .validate("ctx")
2292                .is_err()
2293        );
2294    }
2295
2296    #[test]
2297    fn prior_normal_log_precision_zero_sd_errors() {
2298        assert!(
2299            CoefficientGroupPrior::NormalLogPrecision { mean: 0.0, sd: 0.0 }
2300                .validate("ctx")
2301                .is_err()
2302        );
2303    }
2304
2305    #[test]
2306    fn prior_normal_log_precision_negative_sd_errors() {
2307        assert!(
2308            CoefficientGroupPrior::NormalLogPrecision { mean: 0.0, sd: -1.0 }
2309                .validate("ctx")
2310                .is_err()
2311        );
2312    }
2313
2314    #[test]
2315    fn prior_gamma_precision_valid() {
2316        assert!(
2317            CoefficientGroupPrior::GammaPrecision { shape: 1.0, rate: 0.0 }
2318                .validate("ctx")
2319                .is_ok()
2320        );
2321    }
2322
2323    #[test]
2324    fn prior_gamma_precision_zero_shape_errors() {
2325        assert!(
2326            CoefficientGroupPrior::GammaPrecision { shape: 0.0, rate: 1.0 }
2327                .validate("ctx")
2328                .is_err()
2329        );
2330    }
2331
2332    #[test]
2333    fn prior_gamma_precision_negative_rate_errors() {
2334        assert!(
2335            CoefficientGroupPrior::GammaPrecision { shape: 1.0, rate: -0.1 }
2336                .validate("ctx")
2337                .is_err()
2338        );
2339    }
2340
2341    #[test]
2342    fn prior_penalized_complexity_valid() {
2343        assert!(
2344            CoefficientGroupPrior::PenalizedComplexity { upper: 1.0, tail_prob: 0.05 }
2345                .validate("ctx")
2346                .is_ok()
2347        );
2348    }
2349
2350    #[test]
2351    fn prior_penalized_complexity_zero_upper_errors() {
2352        assert!(
2353            CoefficientGroupPrior::PenalizedComplexity { upper: 0.0, tail_prob: 0.05 }
2354                .validate("ctx")
2355                .is_err()
2356        );
2357    }
2358
2359    #[test]
2360    fn prior_penalized_complexity_tail_prob_zero_errors() {
2361        assert!(
2362            CoefficientGroupPrior::PenalizedComplexity { upper: 1.0, tail_prob: 0.0 }
2363                .validate("ctx")
2364                .is_err()
2365        );
2366    }
2367
2368    #[test]
2369    fn prior_penalized_complexity_tail_prob_one_errors() {
2370        assert!(
2371            CoefficientGroupPrior::PenalizedComplexity { upper: 1.0, tail_prob: 1.0 }
2372                .validate("ctx")
2373                .is_err()
2374        );
2375    }
2376
2377    // -----------------------------------------------------------------------
2378    // LatentCLogLogState::new
2379    // -----------------------------------------------------------------------
2380
2381    #[test]
2382    fn latent_cloglog_zero_sd_ok() {
2383        assert!(LatentCLogLogState::new(0.0).is_ok());
2384    }
2385
2386    #[test]
2387    fn latent_cloglog_positive_sd_ok() {
2388        assert!(LatentCLogLogState::new(1.5).is_ok());
2389    }
2390
2391    #[test]
2392    fn latent_cloglog_negative_sd_errors() {
2393        assert!(LatentCLogLogState::new(-0.1).is_err());
2394    }
2395
2396    #[test]
2397    fn latent_cloglog_infinite_sd_errors() {
2398        assert!(LatentCLogLogState::new(f64::INFINITY).is_err());
2399    }
2400
2401    #[test]
2402    fn latent_cloglog_nan_errors() {
2403        assert!(LatentCLogLogState::new(f64::NAN).is_err());
2404    }
2405
2406    // -----------------------------------------------------------------------
2407    // WigglePenaltyConfig::cubic_triple_operator_default
2408    // -----------------------------------------------------------------------
2409
2410    #[test]
2411    fn wiggle_penalty_default_fields() {
2412        let cfg = WigglePenaltyConfig::cubic_triple_operator_default();
2413        assert_eq!(cfg.degree, 3);
2414        assert_eq!(cfg.num_internal_knots, 8);
2415        assert_eq!(cfg.penalty_orders, vec![1, 2, 3]);
2416        assert!(cfg.double_penalty);
2417        assert!((cfg.monotonicity_eps - 1e-4).abs() < 1e-15);
2418    }
2419
2420    // -----------------------------------------------------------------------
2421    // is_valid_tweedie_power
2422    // -----------------------------------------------------------------------
2423
2424    #[test]
2425    fn tweedie_power_valid_interior() {
2426        assert!(is_valid_tweedie_power(1.5));
2427        assert!(is_valid_tweedie_power(1.1));
2428        assert!(is_valid_tweedie_power(1.9));
2429    }
2430
2431    #[test]
2432    fn tweedie_power_boundaries_invalid() {
2433        assert!(!is_valid_tweedie_power(1.0));
2434        assert!(!is_valid_tweedie_power(2.0));
2435    }
2436
2437    #[test]
2438    fn tweedie_power_outside_interval_invalid() {
2439        assert!(!is_valid_tweedie_power(0.5));
2440        assert!(!is_valid_tweedie_power(2.5));
2441        assert!(!is_valid_tweedie_power(-1.0));
2442        assert!(!is_valid_tweedie_power(f64::INFINITY));
2443    }
2444
2445    // -----------------------------------------------------------------------
2446    // StandardLink <-> LinkFunction conversions
2447    // -----------------------------------------------------------------------
2448
2449    #[test]
2450    fn standard_link_roundtrip_to_link_function() {
2451        assert_eq!(StandardLink::Logit.as_link_function(), LinkFunction::Logit);
2452        assert_eq!(StandardLink::Probit.as_link_function(), LinkFunction::Probit);
2453        assert_eq!(StandardLink::CLogLog.as_link_function(), LinkFunction::CLogLog);
2454        assert_eq!(StandardLink::Identity.as_link_function(), LinkFunction::Identity);
2455        assert_eq!(StandardLink::Log.as_link_function(), LinkFunction::Log);
2456    }
2457
2458    #[test]
2459    fn standard_link_from_link_function_state_bearing_errors() {
2460        assert!(StandardLink::try_from(LinkFunction::Sas).is_err());
2461        assert!(StandardLink::try_from(LinkFunction::BetaLogistic).is_err());
2462    }
2463
2464    #[test]
2465    fn standard_link_from_link_function_standard_ok() {
2466        assert_eq!(StandardLink::try_from(LinkFunction::Logit), Ok(StandardLink::Logit));
2467        assert_eq!(StandardLink::try_from(LinkFunction::Log), Ok(StandardLink::Log));
2468    }
2469
2470    // -----------------------------------------------------------------------
2471    // LikelihoodSpec: legal-cell matrix
2472    // -----------------------------------------------------------------------
2473
2474    #[test]
2475    fn legal_cells_accepted() {
2476        assert!(LikelihoodSpec::try_new(
2477            ResponseFamily::Gaussian,
2478            InverseLink::Standard(StandardLink::Identity)
2479        )
2480        .is_ok());
2481        assert!(LikelihoodSpec::try_new(
2482            ResponseFamily::Poisson,
2483            InverseLink::Standard(StandardLink::Log)
2484        )
2485        .is_ok());
2486        assert!(LikelihoodSpec::try_new(
2487            ResponseFamily::Gamma,
2488            InverseLink::Standard(StandardLink::Log)
2489        )
2490        .is_ok());
2491        assert!(LikelihoodSpec::try_new(
2492            ResponseFamily::Beta { phi: 1.0 },
2493            InverseLink::Standard(StandardLink::Logit)
2494        )
2495        .is_ok());
2496        assert!(LikelihoodSpec::try_new(
2497            ResponseFamily::Binomial,
2498            InverseLink::Standard(StandardLink::Logit)
2499        )
2500        .is_ok());
2501        assert!(LikelihoodSpec::try_new(
2502            ResponseFamily::Binomial,
2503            InverseLink::Standard(StandardLink::Probit)
2504        )
2505        .is_ok());
2506        assert!(LikelihoodSpec::try_new(
2507            ResponseFamily::Binomial,
2508            InverseLink::Standard(StandardLink::CLogLog)
2509        )
2510        .is_ok());
2511    }
2512
2513    #[test]
2514    fn illegal_cells_rejected() {
2515        assert!(LikelihoodSpec::try_new(
2516            ResponseFamily::Poisson,
2517            InverseLink::Standard(StandardLink::Identity)
2518        )
2519        .is_err());
2520        assert!(LikelihoodSpec::try_new(
2521            ResponseFamily::Gaussian,
2522            InverseLink::Standard(StandardLink::Logit)
2523        )
2524        .is_err());
2525        assert!(LikelihoodSpec::try_new(
2526            ResponseFamily::Binomial,
2527            InverseLink::Standard(StandardLink::Log)
2528        )
2529        .is_err());
2530        assert!(LikelihoodSpec::try_new(
2531            ResponseFamily::Binomial,
2532            InverseLink::Standard(StandardLink::Identity)
2533        )
2534        .is_err());
2535    }
2536
2537    #[test]
2538    fn likelihood_spec_kind_names() {
2539        assert_eq!(LikelihoodSpec::gaussian_identity().name(), "gaussian");
2540        assert_eq!(LikelihoodSpec::poisson_log().name(), "poisson-log");
2541        assert_eq!(LikelihoodSpec::binomial_logit().name(), "binomial-logit");
2542        assert_eq!(LikelihoodSpec::gamma_log().name(), "gamma-log");
2543    }
2544
2545    // -----------------------------------------------------------------------
2546    // ResponseFamily::infer_from_response
2547    // -----------------------------------------------------------------------
2548
2549    #[test]
2550    fn infer_binary_kind_gives_binomial() {
2551        let y = arr1(&[0.0_f64, 1.0]);
2552        let result = ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Binary);
2553        assert!(matches!(result, Ok(ResponseFamily::Binomial)));
2554    }
2555
2556    #[test]
2557    fn infer_categorical_kind_refuses() {
2558        let y = arr1(&[0.0_f64, 1.0]);
2559        let result = ResponseFamily::infer_from_response(
2560            y.view(),
2561            ResponseColumnKind::Categorical { levels: vec!["yes".to_string(), "no".to_string()] },
2562        );
2563        assert!(result.is_err());
2564    }
2565
2566    #[test]
2567    fn infer_numeric_binary_values_gives_binomial() {
2568        let y = arr1(&[0.0_f64, 1.0, 0.0, 1.0, 0.0]);
2569        let result =
2570            ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
2571        assert!(matches!(result, Ok(ResponseFamily::Binomial)));
2572    }
2573
2574    #[test]
2575    fn infer_numeric_count_values_gives_poisson() {
2576        let y = arr1(&[0.0_f64, 1.0, 2.0, 3.0, 5.0]);
2577        let result =
2578            ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
2579        assert!(matches!(result, Ok(ResponseFamily::Poisson)));
2580    }
2581
2582    #[test]
2583    fn infer_numeric_fractional_gives_gaussian() {
2584        let y = arr1(&[1.5_f64, 2.3, 3.7]);
2585        let result =
2586            ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
2587        assert!(matches!(result, Ok(ResponseFamily::Gaussian)));
2588    }
2589
2590    #[test]
2591    fn infer_numeric_negative_gives_gaussian() {
2592        let y = arr1(&[-1.0_f64, 0.0, 1.0]);
2593        let result =
2594            ResponseFamily::infer_from_response(y.view(), ResponseColumnKind::Numeric);
2595        assert!(matches!(result, Ok(ResponseFamily::Gaussian)));
2596    }
2597
2598    // -----------------------------------------------------------------------
2599    // ResponseFamily::validate_response_support
2600    // -----------------------------------------------------------------------
2601
2602    #[test]
2603    fn gaussian_support_accepts_any_finite() {
2604        let y = arr1(&[-100.0_f64, 0.0, 100.0]);
2605        assert!(ResponseFamily::Gaussian.validate_response_support(y.view()).is_ok());
2606    }
2607
2608    #[test]
2609    fn gamma_support_rejects_zero() {
2610        let y = arr1(&[0.0_f64, 1.0, 2.0]);
2611        assert!(ResponseFamily::Gamma.validate_response_support(y.view()).is_err());
2612    }
2613
2614    #[test]
2615    fn gamma_support_rejects_negative() {
2616        let y = arr1(&[-1.0_f64, 1.0]);
2617        assert!(ResponseFamily::Gamma.validate_response_support(y.view()).is_err());
2618    }
2619
2620    #[test]
2621    fn gamma_support_accepts_positive() {
2622        let y = arr1(&[0.1_f64, 1.0, 100.0]);
2623        assert!(ResponseFamily::Gamma.validate_response_support(y.view()).is_ok());
2624    }
2625
2626    #[test]
2627    fn binomial_support_rejects_fractional() {
2628        let y = arr1(&[0.0_f64, 0.5, 1.0]);
2629        assert!(ResponseFamily::Binomial.validate_response_support(y.view()).is_err());
2630    }
2631
2632    #[test]
2633    fn binomial_support_accepts_binary() {
2634        let y = arr1(&[0.0_f64, 1.0, 0.0, 1.0]);
2635        assert!(ResponseFamily::Binomial.validate_response_support(y.view()).is_ok());
2636    }
2637
2638    #[test]
2639    fn poisson_support_rejects_negative() {
2640        let y = arr1(&[-1.0_f64, 0.0, 1.0]);
2641        assert!(ResponseFamily::Poisson.validate_response_support(y.view()).is_err());
2642    }
2643
2644    #[test]
2645    fn poisson_support_accepts_nonneg() {
2646        let y = arr1(&[0.0_f64, 1.0, 2.0, 10.0]);
2647        assert!(ResponseFamily::Poisson.validate_response_support(y.view()).is_ok());
2648    }
2649
2650    #[test]
2651    fn beta_support_rejects_zero_boundary() {
2652        let y = arr1(&[0.0_f64, 0.5]);
2653        assert!(ResponseFamily::Beta { phi: 1.0 }.validate_response_support(y.view()).is_err());
2654    }
2655
2656    #[test]
2657    fn beta_support_rejects_one_boundary() {
2658        let y = arr1(&[0.5_f64, 1.0]);
2659        assert!(ResponseFamily::Beta { phi: 1.0 }.validate_response_support(y.view()).is_err());
2660    }
2661
2662    #[test]
2663    fn beta_support_accepts_open_interval() {
2664        let y = arr1(&[0.1_f64, 0.5, 0.9]);
2665        assert!(ResponseFamily::Beta { phi: 1.0 }.validate_response_support(y.view()).is_ok());
2666    }
2667
2668    // -----------------------------------------------------------------------
2669    // ResponseFamily::validate_response_degeneracy
2670    // -----------------------------------------------------------------------
2671
2672    #[test]
2673    fn binomial_degeneracy_all_zeros_errors() {
2674        let y = arr1(&[0.0_f64, 0.0, 0.0]);
2675        assert!(ResponseFamily::Binomial.validate_response_degeneracy(y.view()).is_err());
2676    }
2677
2678    #[test]
2679    fn binomial_degeneracy_all_ones_errors() {
2680        let y = arr1(&[1.0_f64, 1.0, 1.0]);
2681        assert!(ResponseFamily::Binomial.validate_response_degeneracy(y.view()).is_err());
2682    }
2683
2684    #[test]
2685    fn binomial_degeneracy_mixed_ok() {
2686        let y = arr1(&[0.0_f64, 1.0, 0.0]);
2687        assert!(ResponseFamily::Binomial.validate_response_degeneracy(y.view()).is_ok());
2688    }
2689
2690    #[test]
2691    fn gaussian_degeneracy_exactly_constant_ok() {
2692        // A *genuinely* zero-variance response — every value bit-for-bit
2693        // identical — is the well-posed constant limit, not the #332
2694        // divergence: the fit collapses to the constant (intercept = the shared
2695        // value, smooths shrunk to zero). The guard must accept it and let the
2696        // fitter return the constant surface (#1856); only a response that
2697        // varies below the sd floor without being exactly constant keeps the
2698        // rejection (see `gaussian_degeneracy_near_constant_reproducer_errors`).
2699        let y = arr1(&[1.0_f64, 1.0, 1.0]);
2700        assert!(ResponseFamily::Gaussian
2701            .validate_response_degeneracy(y.view())
2702            .is_ok());
2703    }
2704
2705    #[test]
2706    fn gaussian_degeneracy_near_constant_reproducer_errors() {
2707        // The issue reproducer: a response with sd ~ 1e-13, well below the
2708        // `1e-10` floor, so the REML score blows up to +inf without the guard.
2709        let y = arr1(&[
2710            5.0_f64,
2711            5.0 + 1.0e-13,
2712            5.0 - 1.0e-13,
2713            5.0 + 2.0e-13,
2714            5.0 - 2.0e-13,
2715        ]);
2716        let err = ResponseFamily::Gaussian
2717            .validate_response_degeneracy(y.view())
2718            .expect_err("near-constant Gaussian response must be rejected");
2719        match err.kind {
2720            ResponseDegeneracyKind::GaussianNearConstant { sample_sd, min_sd } => {
2721                assert!(
2722                    sample_sd <= min_sd,
2723                    "guard must fire only when sample_sd ({sample_sd:.3e}) <= min_sd ({min_sd:.0e})"
2724                );
2725                assert_eq!(min_sd, GAUSSIAN_MIN_SAMPLE_SD);
2726                // The message quotes both numbers verbatim.
2727                let msg = err.message_for("y");
2728                assert!(msg.contains("effectively constant"), "msg = {msg}");
2729            }
2730            other => panic!("expected GaussianNearConstant, got {other:?}"),
2731        }
2732    }
2733
2734    #[test]
2735    fn gaussian_degeneracy_well_conditioned_ok() {
2736        // A genuinely varying response (sd ~ O(1)) is never tripped.
2737        let y = arr1(&[-2.0_f64, 0.5, 1.7, 3.0, -1.1, 2.2]);
2738        assert!(ResponseFamily::Gaussian
2739            .validate_response_degeneracy(y.view())
2740            .is_ok());
2741    }
2742
2743    #[test]
2744    fn gaussian_degeneracy_small_signal_above_floor_ok() {
2745        // sd ~ 1e-6 is small but far above the 1e-10 floor: a legitimately
2746        // small-but-real signal (e.g. a finely-resolved measurement) must fit,
2747        // so the guard must not over-reject.
2748        let y = arr1(&[1.0_f64, 1.0 + 1.0e-6, 1.0 - 1.0e-6, 1.0 + 2.0e-6]);
2749        assert!(ResponseFamily::Gaussian
2750            .validate_response_degeneracy(y.view())
2751            .is_ok());
2752    }
2753
2754    #[test]
2755    fn gaussian_degeneracy_single_observation_ok() {
2756        // Fewer than two observations carries no estimable scale degeneracy;
2757        // the sample-size gate handles too-small data separately.
2758        let y = arr1(&[42.0_f64]);
2759        assert!(ResponseFamily::Gaussian
2760            .validate_response_degeneracy(y.view())
2761            .is_ok());
2762    }
2763
2764    // -----------------------------------------------------------------------
2765    // ResponseFamily::mean_clamp_bounds / response_support_bounds
2766    // -----------------------------------------------------------------------
2767
2768    #[test]
2769    fn mean_clamp_bounds_binomial_unit_interval() {
2770        assert_eq!(ResponseFamily::Binomial.mean_clamp_bounds(), Some((0.0, 1.0)));
2771    }
2772
2773    #[test]
2774    fn mean_clamp_bounds_gaussian_none() {
2775        assert_eq!(ResponseFamily::Gaussian.mean_clamp_bounds(), None);
2776    }
2777
2778    #[test]
2779    fn mean_clamp_bounds_poisson_none() {
2780        assert_eq!(ResponseFamily::Poisson.mean_clamp_bounds(), None);
2781    }
2782
2783    #[test]
2784    fn response_support_bounds_gamma_nonneg_to_inf() {
2785        assert_eq!(
2786            ResponseFamily::Gamma.response_support_bounds(),
2787            Some((0.0, f64::INFINITY))
2788        );
2789    }
2790
2791    #[test]
2792    fn response_support_bounds_binomial_unit_interval() {
2793        assert_eq!(ResponseFamily::Binomial.response_support_bounds(), Some((0.0, 1.0)));
2794    }
2795
2796    #[test]
2797    fn response_support_bounds_gaussian_none() {
2798        assert_eq!(ResponseFamily::Gaussian.response_support_bounds(), None);
2799    }
2800
2801    // -----------------------------------------------------------------------
2802    // ResponseSupportViolation::message_for
2803    // -----------------------------------------------------------------------
2804
2805    #[test]
2806    fn violation_message_names_column() {
2807        let y = arr1(&[-1.0_f64]);
2808        let err = ResponseFamily::Gamma
2809            .validate_response_support(y.view())
2810            .unwrap_err();
2811        let msg = err.message_for("my_column");
2812        assert!(msg.contains("my_column"), "message: {msg}");
2813        assert!(msg.contains("Gamma"), "message: {msg}");
2814    }
2815
2816    // -----------------------------------------------------------------------
2817    // inverse_link_to_binomial_spec
2818    // -----------------------------------------------------------------------
2819
2820    #[test]
2821    fn binomial_spec_from_logit_link_ok() {
2822        let link = InverseLink::Standard(StandardLink::Logit);
2823        assert!(inverse_link_to_binomial_spec(&link).is_ok());
2824    }
2825
2826    #[test]
2827    fn binomial_spec_from_log_link_errors() {
2828        let link = InverseLink::Standard(StandardLink::Log);
2829        assert!(inverse_link_to_binomial_spec(&link).is_err());
2830    }
2831
2832    #[test]
2833    fn binomial_spec_from_identity_link_errors() {
2834        let link = InverseLink::Standard(StandardLink::Identity);
2835        assert!(inverse_link_to_binomial_spec(&link).is_err());
2836    }
2837
2838    // -----------------------------------------------------------------------
2839    // FamilySpecKind::name / pretty_name
2840    // -----------------------------------------------------------------------
2841
2842    #[test]
2843    fn family_spec_kind_name_non_binomial_variants() {
2844        assert_eq!(FamilySpecKind::GaussianIdentity.name(), "gaussian");
2845        assert_eq!(FamilySpecKind::PoissonLog.name(), "poisson-log");
2846        assert_eq!(FamilySpecKind::GammaLog.name(), "gamma-log");
2847        assert_eq!(FamilySpecKind::TweedieLog { p: 1.5 }.name(), "tweedie-log");
2848        assert_eq!(
2849            FamilySpecKind::NegativeBinomialLog { theta: 2.0 }.name(),
2850            "negative-binomial-log"
2851        );
2852        assert_eq!(FamilySpecKind::BetaLogit { phi: 5.0 }.name(), "beta-regression-logit");
2853        assert_eq!(FamilySpecKind::RoystonParmar.name(), "royston-parmar");
2854    }
2855
2856    #[test]
2857    fn family_spec_kind_name_binomial_variants() {
2858        assert_eq!(FamilySpecKind::BinomialLogit.name(), "binomial-logit");
2859        assert_eq!(FamilySpecKind::BinomialProbit.name(), "binomial-probit");
2860        assert_eq!(FamilySpecKind::BinomialCLogLog.name(), "binomial-cloglog");
2861    }
2862
2863    #[test]
2864    fn family_spec_kind_pretty_name_gaussian() {
2865        assert_eq!(FamilySpecKind::GaussianIdentity.pretty_name(), "Gaussian Identity");
2866    }
2867
2868    #[test]
2869    fn family_spec_kind_pretty_name_binomial_logit() {
2870        assert_eq!(FamilySpecKind::BinomialLogit.pretty_name(), "Binomial Logit");
2871    }
2872
2873    // -----------------------------------------------------------------------
2874    // FamilySpecKind::is_binomial and companions
2875    // -----------------------------------------------------------------------
2876
2877    #[test]
2878    fn is_binomial_true_for_all_binomial_variants() {
2879        assert!(FamilySpecKind::BinomialLogit.is_binomial());
2880        assert!(FamilySpecKind::BinomialProbit.is_binomial());
2881        assert!(FamilySpecKind::BinomialCLogLog.is_binomial());
2882    }
2883
2884    #[test]
2885    fn is_binomial_false_for_non_binomial_variants() {
2886        assert!(!FamilySpecKind::GaussianIdentity.is_binomial());
2887        assert!(!FamilySpecKind::PoissonLog.is_binomial());
2888        assert!(!FamilySpecKind::GammaLog.is_binomial());
2889        assert!(!FamilySpecKind::RoystonParmar.is_binomial());
2890        assert!(!FamilySpecKind::TweedieLog { p: 1.5 }.is_binomial());
2891        assert!(!FamilySpecKind::NegativeBinomialLog { theta: 1.0 }.is_binomial());
2892        assert!(!FamilySpecKind::BetaLogit { phi: 1.0 }.is_binomial());
2893    }
2894
2895    #[test]
2896    fn is_gaussian_identity_true_only_for_gaussian() {
2897        assert!(FamilySpecKind::GaussianIdentity.is_gaussian_identity());
2898        assert!(!FamilySpecKind::PoissonLog.is_gaussian_identity());
2899        assert!(!FamilySpecKind::BinomialLogit.is_gaussian_identity());
2900    }
2901
2902    #[test]
2903    fn is_royston_parmar_true_only_for_royston_parmar() {
2904        assert!(FamilySpecKind::RoystonParmar.is_royston_parmar());
2905        assert!(!FamilySpecKind::GaussianIdentity.is_royston_parmar());
2906        assert!(!FamilySpecKind::BinomialLogit.is_royston_parmar());
2907    }
2908
2909    #[test]
2910    fn supports_firth_iff_is_binomial() {
2911        assert!(FamilySpecKind::BinomialLogit.supports_firth());
2912        assert!(FamilySpecKind::BinomialProbit.supports_firth());
2913        assert!(FamilySpecKind::BinomialCLogLog.supports_firth());
2914        assert!(!FamilySpecKind::GaussianIdentity.supports_firth());
2915        assert!(!FamilySpecKind::PoissonLog.supports_firth());
2916        assert!(!FamilySpecKind::GammaLog.supports_firth());
2917        assert!(!FamilySpecKind::RoystonParmar.supports_firth());
2918    }
2919}