Skip to main content

gam_spec/
lib.rs

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