Skip to main content

gam_spec/
lib.rs

1use ndarray::{Array1, ArrayView1};
2use serde::{Deserialize, Serialize};
3
4/// Hyperprior placed on a coefficient group's precision / log-precision.
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
6pub enum CoefficientGroupPrior {
7    Flat,
8    NormalLogPrecision {
9        mean: f64,
10        sd: f64,
11    },
12    GammaPrecision {
13        shape: f64,
14        rate: f64,
15    },
16    /// Penalized-complexity prior calibrated by `P(exp(-rho/2) > upper) =
17    /// tail_prob`; see [`RhoPrior::PenalizedComplexity`].
18    PenalizedComplexity {
19        upper: f64,
20        tail_prob: f64,
21    },
22}
23
24impl CoefficientGroupPrior {
25    pub fn to_rho_prior(&self) -> RhoPrior {
26        match *self {
27            Self::Flat => RhoPrior::Flat,
28            Self::NormalLogPrecision { mean, sd } => RhoPrior::Normal { mean, sd },
29            Self::GammaPrecision { shape, rate } => RhoPrior::GammaPrecision { shape, rate },
30            Self::PenalizedComplexity { upper, tail_prob } => {
31                RhoPrior::PenalizedComplexity { upper, tail_prob }
32            }
33        }
34    }
35
36    pub fn validate(&self, context: &str) -> Result<(), String> {
37        match *self {
38            Self::Flat => Ok(()),
39            Self::NormalLogPrecision { mean, sd } => {
40                if !mean.is_finite() {
41                    return Err(format!(
42                        "{context} Normal log-precision prior requires finite mean, got {mean}"
43                    ));
44                }
45                if !sd.is_finite() || sd <= 0.0 {
46                    return Err(format!(
47                        "{context} Normal log-precision prior requires sd > 0, got {sd}"
48                    ));
49                }
50                Ok(())
51            }
52            Self::GammaPrecision { shape, rate } => {
53                if !shape.is_finite() || shape <= 0.0 {
54                    return Err(format!(
55                        "{context} Gamma precision prior requires shape > 0, got {shape}"
56                    ));
57                }
58                if !rate.is_finite() || rate < 0.0 {
59                    return Err(format!(
60                        "{context} Gamma precision prior requires rate >= 0, got {rate}"
61                    ));
62                }
63                Ok(())
64            }
65            Self::PenalizedComplexity { upper, tail_prob } => {
66                if !upper.is_finite() || upper <= 0.0 {
67                    return Err(format!(
68                        "{context} penalized-complexity prior requires upper > 0, got {upper}"
69                    ));
70                }
71                if !tail_prob.is_finite() || tail_prob <= 0.0 || tail_prob >= 1.0 {
72                    return Err(format!(
73                        "{context} penalized-complexity prior requires tail probability in (0, 1), got {tail_prob}"
74                    ));
75                }
76                Ok(())
77            }
78        }
79    }
80}
81
82/// Shared default for monotone wiggle/deviation blocks. Formula DSL defaults,
83/// workflow configs, and runtime deviation blocks should all derive from this
84/// type so reproducible presets do not drift across layers.
85#[derive(Clone, Debug, Serialize, Deserialize)]
86pub struct WigglePenaltyConfig {
87    pub degree: usize,
88    pub num_internal_knots: usize,
89    pub penalty_orders: Vec<usize>,
90    pub double_penalty: bool,
91    pub monotonicity_eps: f64,
92}
93
94impl WigglePenaltyConfig {
95    pub fn cubic_triple_operator_default() -> Self {
96        Self {
97            degree: 3,
98            num_internal_knots: 8,
99            penalty_orders: vec![1, 2, 3],
100            double_penalty: true,
101            monotonicity_eps: 1e-4,
102        }
103    }
104}
105
106/// Shared engine-level link selector for generalized models. This is the
107/// "wide" link descriptor: CLI parsing, formula DSL, and the projection from
108/// `InverseLink::link_function()` all live in this enum, so it carries every
109/// link kind the engine knows about — including the state-bearing
110/// `Sas` / `BetaLogistic` cases.
111///
112/// `LinkFunction` is *not* the right type for the state-less `InverseLink::Standard`
113/// cell. Use [`StandardLink`] there: the type system then refuses to construct
114/// a state-less `Standard(Sas)` / `Standard(BetaLogistic)` placeholder.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub enum LinkFunction {
117    Logit,
118    Probit,
119    CLogLog,
120    Sas,
121    BetaLogistic,
122    Identity,
123    Log,
124}
125
126impl LinkFunction {
127    #[inline]
128    pub const fn name(self) -> &'static str {
129        match self {
130            Self::Logit => "logit",
131            Self::Probit => "probit",
132            Self::CLogLog => "cloglog",
133            Self::Sas => "sas",
134            Self::BetaLogistic => "beta-logistic",
135            Self::Identity => "identity",
136            Self::Log => "log",
137        }
138    }
139}
140
141/// Legal-only link descriptor for the state-less `InverseLink::Standard` cell.
142///
143/// `Sas` / `BetaLogistic` are state-bearing and live in their own
144/// `InverseLink::Sas(_)` / `InverseLink::BetaLogistic(_)` variants. The type
145/// system enforces that fact by omitting them here, so the historical
146/// "state-less placeholder" pattern (`InverseLink::Standard(LinkFunction::Sas)`)
147/// no longer compiles.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149pub enum StandardLink {
150    Logit,
151    Probit,
152    CLogLog,
153    Identity,
154    Log,
155}
156
157impl StandardLink {
158    #[inline]
159    pub const fn name(self) -> &'static str {
160        self.as_link_function().name()
161    }
162
163    #[inline]
164    pub const fn as_link_function(self) -> LinkFunction {
165        match self {
166            Self::Logit => LinkFunction::Logit,
167            Self::Probit => LinkFunction::Probit,
168            Self::CLogLog => LinkFunction::CLogLog,
169            Self::Identity => LinkFunction::Identity,
170            Self::Log => LinkFunction::Log,
171        }
172    }
173}
174
175impl From<StandardLink> for LinkFunction {
176    #[inline]
177    fn from(link: StandardLink) -> Self {
178        link.as_link_function()
179    }
180}
181
182/// Error returned when narrowing a wide [`LinkFunction`] into a [`StandardLink`].
183/// `Sas` and `BetaLogistic` are state-bearing and have no legal `Standard(_)`
184/// representation; they must be routed through `InverseLink::Sas` /
185/// `InverseLink::BetaLogistic`.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct StateBearingLinkInStandardSlot(pub LinkFunction);
188
189impl std::fmt::Display for StateBearingLinkInStandardSlot {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        write!(
192            f,
193            "state-bearing link `{}` cannot be carried by `InverseLink::Standard`; \
194             route through `InverseLink::Sas` / `InverseLink::BetaLogistic`",
195            self.0.name()
196        )
197    }
198}
199
200impl std::error::Error for StateBearingLinkInStandardSlot {}
201
202impl TryFrom<LinkFunction> for StandardLink {
203    type Error = StateBearingLinkInStandardSlot;
204
205    #[inline]
206    fn try_from(link: LinkFunction) -> Result<Self, Self::Error> {
207        match link {
208            LinkFunction::Logit => Ok(Self::Logit),
209            LinkFunction::Probit => Ok(Self::Probit),
210            LinkFunction::CLogLog => Ok(Self::CLogLog),
211            LinkFunction::Identity => Ok(Self::Identity),
212            LinkFunction::Log => Ok(Self::Log),
213            LinkFunction::Sas | LinkFunction::BetaLogistic => {
214                Err(StateBearingLinkInStandardSlot(link))
215            }
216        }
217    }
218}
219
220/// Supported inverse-link components for convex blended inverse links.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
222pub enum LinkComponent {
223    Probit,
224    Logit,
225    CLogLog,
226    LogLog,
227    Cauchit,
228}
229
230impl LinkComponent {
231    #[inline]
232    pub const fn name(self) -> &'static str {
233        match self {
234            Self::Probit => "probit",
235            Self::Logit => "logit",
236            Self::CLogLog => "cloglog",
237            Self::LogLog => "loglog",
238            Self::Cauchit => "cauchit",
239        }
240    }
241}
242
243/// User-facing configuration for a blended inverse link.
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct MixtureLinkSpec {
246    pub components: Vec<LinkComponent>,
247    /// Free logits for components [0..K-2]. The final component logit is fixed at 0.
248    pub initial_rho: Array1<f64>,
249}
250
251/// Runtime blended-link state with precomputed softmax weights.
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct MixtureLinkState {
254    pub components: Vec<LinkComponent>,
255    /// Free logits for components [0..K-2]. The final component logit is fixed at 0.
256    pub rho: Array1<f64>,
257    /// Softmax-normalized component weights (length K).
258    pub pi: Array1<f64>,
259}
260
261/// User-facing configuration for the continuous sinh-arcsinh inverse link.
262#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
263pub struct SasLinkSpec {
264    pub initial_epsilon: f64,
265    pub initial_log_delta: f64,
266}
267
268/// Runtime state shared by the two-parameter `Sas` and `BetaLogistic` links:
269/// an `epsilon` skew/asymmetry term plus a raw log-scale parameter (`log_delta`)
270/// and its derived positive companion (`delta`). The `delta` field's meaning is
271/// link-specific — see its doc — so derivative kernels must consume `log_delta`,
272/// never `delta`.
273#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
274pub struct SasLinkState {
275    pub epsilon: f64,
276    /// Raw optimization parameter. For `Sas` this is the pre-bound log-tail; for
277    /// `BetaLogistic` it is the unconstrained log geometric-mean beta shape (the
278    /// `log_shape_center` the beta-logistic kernels expect).
279    pub log_delta: f64,
280    /// Derived positive companion of `log_delta`. Its meaning depends on the link:
281    /// - `Sas`: effective tail parameter `delta = exp(B * tanh(log_delta / B))`,
282    ///   `B = SAS_LOG_DELTA_BOUND`.
283    /// - `BetaLogistic`: geometric-mean beta shape `exp(log_delta) = sqrt(a*b)`.
284    /// The beta-logistic derivative kernels take `log_delta` (the log center), so
285    /// passing this exponentiated `delta` to them would be off by an `exp`.
286    pub delta: f64,
287}
288
289/// Fixed latent Gaussian scale for the exact marginal cloglog family.
290#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
291pub struct LatentCLogLogState {
292    pub latent_sd: f64,
293}
294
295impl LatentCLogLogState {
296    #[inline]
297    pub fn new(latent_sd: f64) -> Result<Self, String> {
298        if !latent_sd.is_finite() || latent_sd < 0.0 {
299            return Err(format!(
300                "latent cloglog standard deviation must be finite and >= 0, got {latent_sd}"
301            ));
302        }
303        Ok(Self { latent_sd })
304    }
305}
306
307/// Whether the inverse link exposes the Fisher-weight jet that the Firth
308/// penalty's higher-order correction consumes. Inlined here (issue #1521) so
309/// `LikelihoodSpec::supports_firth` has no upward dependency on
310/// `solver::mixture_link` — the match is over link variants all defined in this
311/// module, so the predicate is self-contained. The canonical jet evaluation
312/// still lives in `solver::mixture_link`; this is purely the classifier.
313#[inline]
314fn inverse_link_has_fisher_weight_jet(link: &InverseLink) -> bool {
315    matches!(
316        link,
317        InverseLink::Standard(StandardLink::Logit | StandardLink::Probit | StandardLink::CLogLog,)
318            | InverseLink::LatentCLogLog(_)
319            | InverseLink::Sas(_)
320            | InverseLink::BetaLogistic(_)
321            | InverseLink::Mixture(_)
322    )
323}
324
325/// Parameterized inverse-link selector used where mu/derivatives are evaluated.
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327pub enum InverseLink {
328    Standard(StandardLink),
329    LatentCLogLog(LatentCLogLogState),
330    Sas(SasLinkState),
331    BetaLogistic(SasLinkState),
332    Mixture(MixtureLinkState),
333}
334
335impl InverseLink {
336    #[inline]
337    pub const fn link_function(&self) -> LinkFunction {
338        match self {
339            Self::Standard(link) => link.as_link_function(),
340            Self::LatentCLogLog(_) => LinkFunction::CLogLog,
341            Self::Sas(_) => LinkFunction::Sas,
342            Self::BetaLogistic(_) => LinkFunction::BetaLogistic,
343            Self::Mixture(_) => LinkFunction::Logit,
344        }
345    }
346
347    #[inline]
348    pub const fn mixture_state(&self) -> Option<&MixtureLinkState> {
349        match self {
350            Self::Mixture(state) => Some(state),
351            _ => None,
352        }
353    }
354
355    #[inline]
356    pub const fn sas_state(&self) -> Option<&SasLinkState> {
357        match self {
358            Self::Sas(state) | Self::BetaLogistic(state) => Some(state),
359            _ => None,
360        }
361    }
362
363    #[inline]
364    pub const fn latent_cloglog_state(&self) -> Option<&LatentCLogLogState> {
365        match self {
366            Self::LatentCLogLog(state) => Some(state),
367            _ => None,
368        }
369    }
370}
371
372/// Fixed prior family for smoothing parameters in joint HMC refinement.
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374pub enum RhoPrior {
375    Flat,
376    Normal {
377        mean: f64,
378        sd: f64,
379    },
380    /// Gamma(shape, rate) conjugate hyperprior on the precision lambda = exp(rho).
381    ///
382    /// The deterministic REML/LAML objective uses the MAP-in-lambda convention
383    /// and is minimized, so this contributes `rate * exp(rho) - (shape - 1) * rho`
384    /// up to an additive constant. Samplers over rho include the +rho Jacobian
385    /// from lambda = exp(rho), so their log-density contribution is
386    /// `shape * rho - rate * exp(rho)`. For a block with effective dimension n_p
387    /// and centered quadratic
388    /// `(beta - mu)'S_p(beta - mu)`, the conditional posterior is
389    /// `Gamma(shape + n_p/2, rate + quadratic/2)` and the closed-form MAP
390    /// precision is `(shape + n_p/2 - 1) / (rate + quadratic/2)`.
391    /// `Gamma(1, 0)` is the explicit flat/default case and reproduces the
392    /// current MacKay/Tipping fixed point.
393    GammaPrecision {
394        shape: f64,
395        rate: f64,
396    },
397    /// Penalized-complexity (PC) prior on the smoothing parameter
398    /// (Simpson, Rue, Riebler, Martins, Sørbye, *Statistical Science* 2017).
399    ///
400    /// A PC prior fixes a *base* model (here the infinitely-smooth limit, where
401    /// the penalized component collapses to its null space) and puts an
402    /// exponential prior on the distance away from it. For a Gaussian smooth
403    /// with precision `λ = exp(ρ)` the relevant distance is the marginal
404    /// standard-deviation scale `d = λ^{-1/2} = exp(-ρ/2)`, and a constant-rate
405    /// penalization `p(d) = θ exp(-θ d)` induces the closed-form log-prior
406    ///
407    /// ```text
408    /// log p(ρ) = ln(θ/2) − ρ/2 − θ exp(−ρ/2).
409    /// ```
410    ///
411    /// The rate `θ` is calibrated by the single interpretable tail statement
412    /// `P(d > upper) = tail_prob`, i.e. `θ = −ln(tail_prob) / upper`. The prior
413    /// is reparameterization-invariant and shrinks toward the simpler model
414    /// (an exponential wall against under-smoothing, only a gentle linear pull
415    /// toward over-smoothing), which is exactly the Occam behaviour wanted for
416    /// high-variance flexible components. The REML/LAML objective is minimized,
417    /// so this contributes `ρ/2 + θ exp(−ρ/2)` (up to an additive constant) to
418    /// the cost, with gradient `1/2 − (θ/2) exp(−ρ/2)` and (always positive)
419    /// curvature `(θ/4) exp(−ρ/2)`.
420    PenalizedComplexity {
421        /// Upper bound `U` on the distance scale `d = exp(-ρ/2)` (the marginal
422        /// SD scale of the penalized component) in the tail statement
423        /// `P(d > U) = tail_prob`. Must be finite and strictly positive.
424        upper: f64,
425        /// Tail probability `α` in `P(d > U) = α`. Must satisfy `0 < α < 1`.
426        tail_prob: f64,
427    },
428    /// Coordinate-specific priors for models whose smoothing parameters do
429    /// not share one prior family, such as nested coefficient groups.
430    Independent(Vec<RhoPrior>),
431}
432
433impl Default for RhoPrior {
434    fn default() -> Self {
435        Self::Normal { mean: 0.0, sd: 3.0 }
436    }
437}
438
439// ---------------------------------------------------------------------------
440// Unified likelihood specification
441// ---------------------------------------------------------------------------
442//
443// `LikelihoodSpec { response: ResponseFamily, link: InverseLink }` is the
444// canonical likelihood selector. `ResponseFamily` is a pure response-
445// distribution selector that carries the per-family scalars
446// (`Tweedie { p }`, `NegativeBinomial { theta }`, `Beta { phi }`); `InverseLink`
447// is the parameterized inverse-link selector. Splitting (response, link)
448// removes the drift bug that the former flat likelihood enum allowed
449// when its variant disagreed with a separately-stored `InverseLink`.
450
451/// Pure response distribution selector — no link information.
452#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
453pub enum ResponseFamily {
454    Gaussian,
455    Binomial,
456    Poisson,
457    Tweedie {
458        p: f64,
459    },
460    NegativeBinomial {
461        theta: f64,
462        /// `true` when `theta` was supplied by the user as a held-fixed value
463        /// (`--negative-binomial-theta`, issue #983): the fit must use exactly
464        /// this overdispersion — `Var(y) = μ + μ²/θ`, IRLS weight
465        /// `W = μθ/(θ+μ)`, coefficients/covariance/SEs all reflect it — and
466        /// the inner solver must never overwrite it. `false` means `theta` is
467        /// the running seed/estimate refined from the data each inner solve
468        /// (the #802 default). Carried on the family variant — the canonical
469        /// `theta` store — so the estimated-vs-fixed contract can never desync
470        /// from the value itself; `default_scale_metadata` derives the
471        /// matching scale variant.
472        theta_fixed: bool,
473    },
474    Beta {
475        phi: f64,
476    },
477    Gamma,
478    RoystonParmar,
479}
480
481impl ResponseFamily {
482    #[inline]
483    pub const fn name(&self) -> &'static str {
484        match self {
485            Self::Gaussian => "gaussian",
486            Self::Binomial => "binomial",
487            Self::Poisson => "poisson",
488            Self::Tweedie { .. } => "tweedie",
489            Self::NegativeBinomial { .. } => "negative-binomial",
490            Self::Beta { .. } => "beta",
491            Self::Gamma => "gamma",
492            Self::RoystonParmar => "royston-parmar",
493        }
494    }
495
496    /// Closed-interval bounds for the mean (response-scale) of this family.
497    ///
498    /// Used by predict-side CI clamps that need to keep transformed bounds
499    /// within the support of the response. Beta uses strict-open `(1e-10, 1 − 1e-10)`
500    /// to avoid logit singularities; Binomial / Royston-Parmar use the closed
501    /// `[0, 1]` since they are evaluated post-transformation. Unbounded
502    /// (continuous-real or non-negative-real) families return `None` — the
503    /// caller should not clamp.
504    #[inline]
505    pub fn mean_clamp_bounds(&self) -> Option<(f64, f64)> {
506        match self {
507            Self::Binomial | Self::RoystonParmar => Some((0.0, 1.0)),
508            Self::Beta { .. } => Some((1e-10, 1.0 - 1e-10)),
509            Self::Gaussian
510            | Self::Poisson
511            | Self::Tweedie { .. }
512            | Self::NegativeBinomial { .. }
513            | Self::Gamma => None,
514        }
515    }
516
517    /// Closed numeric bounds of the **response support** — the closure of the
518    /// set of values a single observation `Y` can take — used to clamp the
519    /// *observation (prediction) interval* so a predictive band never reports
520    /// values the response can never attain.
521    ///
522    /// This is deliberately distinct from [`Self::mean_clamp_bounds`], which
523    /// governs the *mean* (confidence) interval. `mean_clamp_bounds` returns
524    /// `None` for the non-negative-real families (Poisson / Tweedie /
525    /// NegativeBinomial / Gamma) because their default mean interval is built
526    /// by transforming the η endpoints through a positive inverse link, which
527    /// cannot escape the support. The observation interval, by contrast, is the
528    /// symmetric response-scale band `μ ± z·σ_pred`; for a small fitted mean its
529    /// lower endpoint crosses below the support floor (e.g. a Poisson count band
530    /// going negative), so it must be floored at the response support here.
531    ///
532    /// The lower edge is the infimum of the support (`0` for every non-negative
533    /// family, including the open-at-zero Gamma, whose predictive lower bound is
534    /// reported at the boundary `0`). The upper edge is `+∞` where the response
535    /// is unbounded above, which leaves the upper band untouched, or `1` for the
536    /// `[0, 1]`-valued families. `None` means the response is supported on the
537    /// whole real line (Gaussian) or has its support enforced downstream
538    /// (Royston–Parmar), and the predictive band is passed through unclamped.
539    ///
540    /// The match arms mirror [`Self::response_support_contains`]: a new family
541    /// must update both together so the support a value is validated against and
542    /// the support a predictive band is clamped to stay consistent.
543    #[inline]
544    pub fn response_support_bounds(&self) -> Option<(f64, f64)> {
545        match self {
546            Self::Gamma | Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
547                Some((0.0, f64::INFINITY))
548            }
549            Self::Beta { .. } | Self::Binomial => Some((0.0, 1.0)),
550            Self::Gaussian | Self::RoystonParmar => None,
551        }
552    }
553
554    /// Per-family textual description of the response-support requirement.
555    /// `None` means the family is supported on the entire real line at the
556    /// validation layer (Gaussian) or has its support enforced by a downstream
557    /// pathway (RoystonParmar via the survival pipeline).
558    ///
559    /// `Binomial` is the scalar Bernoulli-logit family: with no per-row trial
560    /// count `mᵢ`, the log-likelihood is `ℓ(η) = y·η − log(1 + eη)`, which is
561    /// unbounded above for `y ∉ {0, 1}` (as `η → ∞`, `ℓ ~ (y − 1)·η`), and the
562    /// binomial deviance term `(1 − y)·log((1 − y)/(1 − μ))` leaves its domain
563    /// for `y > 1`. The family is therefore only well-posed for `y ∈ {0, 1}`,
564    /// which the support check enforces up front rather than deferring to the
565    /// downstream binarity heuristic.
566    #[inline]
567    pub fn response_support_requirement(&self) -> Option<&'static str> {
568        match self {
569            Self::Gamma => Some("strictly positive response values (y > 0)"),
570            Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
571                Some("non-negative response values (y ≥ 0)")
572            }
573            Self::Beta { .. } => Some("response values strictly in the open interval (0, 1)"),
574            Self::Binomial => Some("binary response values (y ∈ {0, 1})"),
575            Self::Gaussian | Self::RoystonParmar => None,
576        }
577    }
578
579    /// Predicate that returns `true` iff `yi` lies in this family's response
580    /// support. Only meaningful for families with a non-trivial domain
581    /// constraint at the validation layer; `validate_response_support` calls
582    /// this only after `response_support_requirement` returns `Some`, so the
583    /// "unconstrained" families (Gaussian / RoystonParmar) never hit this code
584    /// path.
585    ///
586    /// `Binomial` accepts only an (exactly) binary value: `y` must equal `0` or
587    /// `1` to within `BINOMIAL_BINARY_TOL` — the same `{0, 1}` test used by the
588    /// auto-inference and degeneracy paths — because the scalar Bernoulli-logit
589    /// likelihood is unbounded for any other value (see
590    /// `response_support_requirement`).
591    #[inline]
592    fn response_support_contains(&self, yi: f64) -> bool {
593        match self {
594            Self::Gamma => yi.is_finite() && yi > 0.0,
595            Self::Poisson | Self::NegativeBinomial { .. } | Self::Tweedie { .. } => {
596                yi.is_finite() && yi >= 0.0
597            }
598            Self::Beta { .. } => yi.is_finite() && yi > 0.0 && yi < 1.0,
599            Self::Binomial => {
600                yi.is_finite()
601                    && ((yi - 0.0).abs() < BINOMIAL_BINARY_TOL
602                        || (yi - 1.0).abs() < BINOMIAL_BINARY_TOL)
603            }
604            Self::Gaussian | Self::RoystonParmar => true,
605        }
606    }
607
608    /// Human-readable family label used in domain-violation error messages
609    /// (capitalised to match user-facing prose, distinct from `name()` which
610    /// returns the lowercase canonical identifier).
611    #[inline]
612    fn response_support_label(&self) -> &'static str {
613        match self {
614            Self::Gaussian => "Gaussian",
615            Self::Binomial => "Binomial",
616            Self::Poisson => "Poisson",
617            Self::Tweedie { .. } => "Tweedie",
618            Self::NegativeBinomial { .. } => "Negative-Binomial",
619            Self::Beta { .. } => "Beta",
620            Self::Gamma => "Gamma",
621            Self::RoystonParmar => "Royston-Parmar",
622        }
623    }
624
625    /// Validate that every element of `y` lies in this family's response
626    /// support. The check is the upfront, fit-blocking enforcement of the
627    /// family's distributional support — e.g. Gamma rejects `y ≤ 0` because
628    /// the log-likelihood contains `log(y)`, Poisson rejects `y < 0` because
629    /// the log-mass contains `log(y!)`.
630    ///
631    /// Returns `Ok(())` for families whose support is the entire real line at
632    /// this layer (Gaussian) or whose support is enforced by a downstream
633    /// pathway (RoystonParmar via the survival pipeline). `Binomial` is
634    /// enforced here: only `y ∈ {0, 1}` keeps the scalar Bernoulli-logit
635    /// likelihood bounded.
636    ///
637    /// Up to `ResponseSupportViolation::MAX_REPORTED` offending row indices
638    /// are returned in the violation so the message stays bounded on large
639    /// datasets while still identifying offending rows.
640    pub fn validate_response_support(
641        &self,
642        y: ArrayView1<'_, f64>,
643    ) -> Result<(), ResponseSupportViolation> {
644        let requirement = match self.response_support_requirement() {
645            Some(r) => r,
646            None => return Ok(()),
647        };
648        let mut offending: Vec<(usize, f64)> = Vec::new();
649        let mut total_violations: usize = 0;
650        for (i, &yi) in y.iter().enumerate() {
651            if !self.response_support_contains(yi) {
652                total_violations += 1;
653                if offending.len() < ResponseSupportViolation::MAX_REPORTED {
654                    offending.push((i, yi));
655                }
656            }
657        }
658        if total_violations == 0 {
659            Ok(())
660        } else {
661            Err(ResponseSupportViolation {
662                family_label: self.response_support_label(),
663                requirement,
664                offending,
665                total_violations,
666            })
667        }
668    }
669
670    /// Detect a *degenerate* response: one whose value distribution makes the
671    /// family's REML log-likelihood non-finite even though every individual
672    /// `y_i` lies inside the family's distributional support.
673    ///
674    /// Symmetric counterpart to [`Self::validate_response_support`]: support
675    /// rejects out-of-domain *values* (e.g. a negative Poisson count); this
676    /// rejects *distributions* that send the saturated MLE to a boundary at
677    /// which the score diverges. Each family answers the question for itself
678    /// — adding a new family does not require touching workflow.rs.
679    ///
680    /// Concretely:
681    /// * `Binomial` — refuses an all-zero or all-one response: the saturated
682    ///   logit is ±∞ and the REML score is +∞ (issue #331).
683    /// * Every other family currently returns `Ok(())` at this layer — the
684    ///   support check already guarantees enough variation to make the
685    ///   log-likelihood finite.
686    pub fn validate_response_degeneracy(
687        &self,
688        y: ArrayView1<'_, f64>,
689    ) -> Result<(), ResponseDegeneracy> {
690        match self {
691            Self::Binomial => {
692                let mut saw_zero = false;
693                let mut saw_one = false;
694                for &yi in y.iter() {
695                    if (yi - 0.0).abs() < BINOMIAL_BINARY_TOL {
696                        saw_zero = true;
697                    } else if (yi - 1.0).abs() < BINOMIAL_BINARY_TOL {
698                        saw_one = true;
699                    }
700                    if saw_zero && saw_one {
701                        return Ok(());
702                    }
703                }
704                let kind = if saw_one {
705                    ResponseDegeneracyKind::BinomialAllOnes
706                } else if saw_zero {
707                    ResponseDegeneracyKind::BinomialAllZeros
708                } else {
709                    // Reachable only for an empty response: the support check
710                    // (`validate_response_support`) has already rejected any
711                    // non-binary value, so every present `yᵢ` is exactly 0 or 1
712                    // and at least one of `saw_zero`/`saw_one` is set whenever
713                    // `y` is non-empty. An empty response carries no
714                    // saturated-boundary degeneracy, so accept it here.
715                    return Ok(());
716                };
717                Err(ResponseDegeneracy {
718                    family_label: self.response_support_label(),
719                    kind,
720                })
721            }
722            Self::Gaussian => Ok(()),
723            Self::Poisson
724            | Self::Tweedie { .. }
725            | Self::NegativeBinomial { .. }
726            | Self::Beta { .. }
727            | Self::Gamma
728            | Self::RoystonParmar => Ok(()),
729        }
730    }
731
732    /// Auto-infer a likelihood family when the user did not specify one.
733    ///
734    /// Policy:
735    ///   * A string-valued (`Categorical`) response column is refused —
736    ///     numeric-encoded level indices (e.g. `"yes"`/`"no"` → `0.0`/`1.0`)
737    ///     would otherwise be silently interpreted as a binary outcome,
738    ///     producing a probability model the user never asked for.
739    ///   * A strictly-binary numeric response (`Binary` kind, or `Numeric`
740    ///     with only `{0, 1}` values) maps to `Binomial`.
741    ///   * A non-negative integer-valued count response (every value finite,
742    ///     `>= 0`, and within [`COUNT_INTEGER_TOL`] of an integer) that reaches
743    ///     beyond the binary `{0, 1}` window (i.e. carries at least one value
744    ///     `>= 2`) maps to `Poisson` (log link). This is the "magic-by-default"
745    ///     count detection: mgcv/statsmodels users expect `0,1,2,3,...` to fit a
746    ///     Poisson GLM, not an identity-link Gaussian.
747    ///   * Anything else (any fractional or negative value) maps to `Gaussian`.
748    ///
749    /// The fallback to `is_binary_response` inside the `Numeric` arm is what
750    /// historically lived directly inside `resolve_family`; centralising the
751    /// policy here means every entry point (formula API, CLI, future bindings)
752    /// gets the same default-inference behaviour.
753    pub fn infer_from_response(
754        y: ArrayView1<'_, f64>,
755        y_kind: ResponseColumnKind,
756    ) -> Result<Self, ResponseInferenceRefusal> {
757        match y_kind {
758            ResponseColumnKind::Categorical { levels } => Err(ResponseInferenceRefusal {
759                reason: ResponseInferenceRefusalReason::NonNumericResponse,
760                levels,
761            }),
762            ResponseColumnKind::Binary => Ok(Self::Binomial),
763            ResponseColumnKind::Numeric => {
764                let binary = !y.is_empty()
765                    && y.iter().all(|v| {
766                        v.is_finite()
767                            && ((*v - 0.0).abs() < BINOMIAL_BINARY_TOL
768                                || (*v - 1.0).abs() < BINOMIAL_BINARY_TOL)
769                    });
770                if binary {
771                    return Ok(Self::Binomial);
772                }
773                // Count signature: every value finite, non-negative, and an
774                // integer within `COUNT_INTEGER_TOL`, with at least one value
775                // `>= 2` so it is not the (already-handled) binary case and not
776                // a degenerate all-zero column. A single fractional or negative
777                // value disqualifies the whole response, keeping continuous and
778                // signed data on the conservative Gaussian default.
779                let count = !y.is_empty()
780                    && y.iter().all(|v| {
781                        v.is_finite() && *v >= 0.0 && (*v - v.round()).abs() <= COUNT_INTEGER_TOL
782                    })
783                    && y.iter().any(|v| *v >= 2.0 - COUNT_INTEGER_TOL);
784                if count {
785                    Ok(Self::Poisson)
786                } else {
787                    Ok(Self::Gaussian)
788                }
789            }
790        }
791    }
792}
793
794/// Domain-violation detail produced by [`ResponseFamily::validate_response_support`].
795///
796/// Owns its own `Display` impl so call sites in the workflow, the CLI, and the
797/// external-design GLM path produce identical user-facing prose. The
798/// `total_violations` counter is kept distinct from `offending.len()` so the
799/// message can honestly say `(N total)` even when only the first
800/// `MAX_REPORTED` indices are surfaced.
801#[derive(Debug, Clone)]
802pub struct ResponseSupportViolation {
803    pub family_label: &'static str,
804    pub requirement: &'static str,
805    pub offending: Vec<(usize, f64)>,
806    pub total_violations: usize,
807}
808
809impl ResponseSupportViolation {
810    /// Maximum number of offending row indices reported in the error message.
811    /// Keeps the message bounded on large-scale data while still pointing
812    /// the user at concrete bad rows to inspect.
813    pub const MAX_REPORTED: usize = 5;
814
815    /// Format the violation against a specific response column name. The
816    /// column name is supplied by the caller because [`ResponseFamily`] does
817    /// not know which column the user pointed at.
818    pub fn message_for(&self, response_name: &str) -> String {
819        let shown = self
820            .offending
821            .iter()
822            .map(|(i, v)| format!("y[{i}]={v}"))
823            .collect::<Vec<_>>()
824            .join(", ");
825        let more = if self.total_violations > self.offending.len() {
826            format!(", ... ({} total)", self.total_violations)
827        } else {
828            String::new()
829        };
830        format!(
831            "{family} family requires {req}; response column '{name}' violates this constraint at row(s) [{shown}{more}]",
832            family = self.family_label,
833            req = self.requirement,
834            name = response_name,
835        )
836    }
837}
838
839impl std::fmt::Display for ResponseSupportViolation {
840    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
841        f.write_str(&self.message_for("y"))
842    }
843}
844
845impl std::error::Error for ResponseSupportViolation {}
846
847/// Absolute tolerance for the exact-`{0, 1}` test that defines the scalar
848/// Bernoulli (`Binomial`) response support.
849///
850/// The scalar `Binomial` family carries no per-row trial count, so its
851/// log-likelihood is the Bernoulli/soft-label cross-entropy
852/// `ℓ(η) = y·η − log(1 + eη)`, which is unbounded above for `y ∉ {0, 1}`.
853/// Both the auto-inference (`infer_from_response`) and degeneracy
854/// (`validate_response_degeneracy`) paths classify a value as binary by the
855/// same `1e-12` window; the support check shares this single threshold so the
856/// three layers agree on exactly which responses are admissible.
857pub const BINOMIAL_BINARY_TOL: f64 = 1.0e-12;
858
859/// Round tolerance for recognising an integer-valued (count) response.
860///
861/// `infer_from_response` classifies a numeric response as a Poisson count when
862/// every value is finite, non-negative, and within this window of its nearest
863/// non-negative integer. The threshold is looser than [`BINOMIAL_BINARY_TOL`]
864/// because count columns frequently arrive as `f64` round-trips of integers
865/// (CSV parse, integer→double promotion) that accumulate ULP-scale error well
866/// above `1e-12`; `1e-9` admits those without ever matching genuinely
867/// continuous data, whose fractional parts are O(1).
868pub const COUNT_INTEGER_TOL: f64 = 1.0e-9;
869
870/// Classifier for a [`ResponseDegeneracy`]. Each variant carries the family-
871/// specific evidence the caller needs to format a useful message without
872/// having to re-derive the diagnostic.
873#[derive(Debug, Clone)]
874pub enum ResponseDegeneracyKind {
875    /// Bernoulli / Binomial response with every observed value equal to 0.
876    BinomialAllZeros,
877    /// Bernoulli / Binomial response with every observed value equal to 1.
878    BinomialAllOnes,
879}
880
881/// Degenerate-response detail produced by
882/// [`ResponseFamily::validate_response_degeneracy`].
883///
884/// Mirrors [`ResponseSupportViolation`]: it owns its own `Display` and
885/// `message_for(column_name)` so call sites in the workflow, the CLI, and
886/// any future binding produce identical user-facing prose without coupling
887/// each one to the family-internal classifier.
888#[derive(Debug, Clone)]
889pub struct ResponseDegeneracy {
890    pub family_label: &'static str,
891    pub kind: ResponseDegeneracyKind,
892}
893
894impl ResponseDegeneracy {
895    /// Format the degeneracy against a specific response column name. The
896    /// column name is supplied by the caller because [`ResponseFamily`] does
897    /// not know which column the user pointed at.
898    pub fn message_for(&self, response_name: &str) -> String {
899        match self.kind {
900            ResponseDegeneracyKind::BinomialAllZeros => format!(
901                "{family} response '{name}' is degenerate: all values are 0 (no events). \
902                 The maximum-likelihood logit is −∞ at this boundary, so the REML score \
903                 is not finite. Fix: ensure the response contains at least one 0 and \
904                 at least one 1 (e.g. drop the offending subgroup, or refit on a pooled \
905                 sample that includes both classes).",
906                family = self.family_label,
907                name = response_name,
908            ),
909            ResponseDegeneracyKind::BinomialAllOnes => format!(
910                "{family} response '{name}' is degenerate: all values are 1 (no non-events). \
911                 The maximum-likelihood logit is +∞ at this boundary, so the REML score \
912                 is not finite. Fix: ensure the response contains at least one 0 and \
913                 at least one 1 (e.g. drop the offending subgroup, or refit on a pooled \
914                 sample that includes both classes).",
915                family = self.family_label,
916                name = response_name,
917            ),
918        }
919    }
920}
921
922impl std::fmt::Display for ResponseDegeneracy {
923    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
924        f.write_str(&self.message_for("y"))
925    }
926}
927
928impl std::error::Error for ResponseDegeneracy {}
929
930/// Caller-supplied description of the response column's *source* kind.
931///
932/// `Categorical { levels }` flags a column that arrived as non-numeric strings
933/// (the ingest layer encoded its levels to `0.0, 1.0, ...` indices) — the
934/// `levels` list is preserved so the auto-inference refusal can echo them
935/// back to the user verbatim. `Binary` is the ingest-layer signal that a
936/// numeric column already contains only `{0, 1}` (used to short-circuit the
937/// scan inside [`ResponseFamily::infer_from_response`]). `Numeric` is the
938/// generic continuous case.
939#[derive(Debug, Clone)]
940pub enum ResponseColumnKind {
941    Numeric,
942    Binary,
943    Categorical { levels: Vec<String> },
944}
945
946/// Reason [`ResponseFamily::infer_from_response`] refused to pick a default
947/// family. Kept as an enum so future policy extensions (e.g. "refuse on
948/// constant response" — currently a separate CLI-side check) can be added
949/// without breaking the call site's match arms.
950#[derive(Debug, Clone)]
951pub enum ResponseInferenceRefusalReason {
952    NonNumericResponse,
953}
954
955/// Auto-inference refusal carrying the levels seen in the source column so
956/// the workflow error can echo them in its message.
957#[derive(Debug, Clone)]
958pub struct ResponseInferenceRefusal {
959    pub reason: ResponseInferenceRefusalReason,
960    pub levels: Vec<String>,
961}
962
963impl ResponseInferenceRefusal {
964    /// Format the refusal against a specific response column name.
965    pub fn message_for(&self, response_name: &str) -> String {
966        match self.reason {
967            ResponseInferenceRefusalReason::NonNumericResponse => {
968                let n = self.levels.len().min(5);
969                let head = self
970                    .levels
971                    .iter()
972                    .take(n)
973                    .map(|s| format!("'{s}'"))
974                    .collect::<Vec<_>>()
975                    .join(", ");
976                let preview = if self.levels.len() > n {
977                    format!("[{head}, ...]")
978                } else {
979                    format!("[{head}]")
980                };
981                format!(
982                    "response column '{name}' contains non-numeric values {preview}. \
983                     Did you mean to use family='binomial' for a binary outcome, \
984                     or does '{name}' contain categorical labels that should be encoded first?",
985                    name = response_name,
986                    preview = preview,
987                )
988            }
989        }
990    }
991}
992
993impl std::fmt::Display for ResponseInferenceRefusal {
994    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
995        f.write_str(&self.message_for("y"))
996    }
997}
998
999impl std::error::Error for ResponseInferenceRefusal {}
1000
1001/// Unified likelihood specification: response distribution + parameterized link.
1002///
1003/// `ResponseFamily` carries the per-family scalars (Tweedie p, NegBin theta,
1004/// Beta phi); `InverseLink` carries the parameterized link state. Together
1005/// they replace the former flat likelihood enum.
1006///
1007/// Only the legal `(response, link)` cells enumerated by [`LikelihoodSpec::kind`]
1008/// are representable through the public surface: [`LikelihoodSpec::try_new`]
1009/// validates the legal matrix on construction, and deserialization routes
1010/// through [`LikelihoodSpecWire`] (`#[serde(try_from / into)]`) so saved bytes
1011/// cannot resurrect an illegal cell. The on-wire shape is byte-identical to the
1012/// historical `{ response, link }` struct, so legal saved models load unchanged.
1013#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1014#[serde(try_from = "LikelihoodSpecWire", into = "LikelihoodSpecWire")]
1015pub struct LikelihoodSpec {
1016    pub response: ResponseFamily,
1017    pub link: InverseLink,
1018}
1019
1020/// Transparent serde shadow of [`LikelihoodSpec`] with the identical wire shape
1021/// (`response`, `link`). All (de)serialization of `LikelihoodSpec` routes
1022/// through this type so the legal-matrix check in
1023/// [`TryFrom<LikelihoodSpecWire>`] runs on every load, closing the
1024/// saved-bytes hole: an illegal `(response, link)` cell deserializes into a
1025/// serde error instead of a silently-masked spec.
1026#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1027pub struct LikelihoodSpecWire {
1028    pub response: ResponseFamily,
1029    pub link: InverseLink,
1030}
1031
1032impl From<LikelihoodSpec> for LikelihoodSpecWire {
1033    #[inline]
1034    fn from(spec: LikelihoodSpec) -> Self {
1035        Self {
1036            response: spec.response,
1037            link: spec.link,
1038        }
1039    }
1040}
1041
1042impl TryFrom<LikelihoodSpecWire> for LikelihoodSpec {
1043    type Error = IllegalLikelihoodCell;
1044
1045    #[inline]
1046    fn try_from(wire: LikelihoodSpecWire) -> Result<Self, Self::Error> {
1047        Self::try_new(wire.response, wire.link)
1048    }
1049}
1050
1051/// Error returned when an illegal `(ResponseFamily, InverseLink)` cell is
1052/// presented to [`LikelihoodSpec::try_new`] or surfaced during
1053/// deserialization. Only the cells enumerated by [`LikelihoodSpec::kind`] are
1054/// legal; every other product cell would silently mask a wrong response
1055/// transformation (e.g. `Poisson + Identity` predicting `μ = η`, which can go
1056/// negative).
1057#[derive(Debug, Clone, PartialEq)]
1058pub struct IllegalLikelihoodCell {
1059    pub response: &'static str,
1060    pub link: &'static str,
1061}
1062
1063impl std::fmt::Display for IllegalLikelihoodCell {
1064    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1065        write!(
1066            f,
1067            "illegal likelihood cell: response `{}` does not admit inverse link `{}`. \
1068             Each non-binomial family is pinned to one link (Gaussian/Royston-Parmar→identity, \
1069             Poisson/Gamma/Tweedie/Negative-Binomial→log, Beta→logit); the binomial family \
1070             admits logit/probit/cloglog and the latent-cloglog/SAS/beta-logistic/blended \
1071             links, but not identity/log.",
1072            self.response, self.link
1073        )
1074    }
1075}
1076
1077impl std::error::Error for IllegalLikelihoodCell {}
1078
1079/// Legal-only enumeration of the `(ResponseFamily, InverseLink)` cells the
1080/// engine recognises. `LikelihoodSpec` is the product type with ~40 nominal
1081/// cells (8 response variants × 5 inverse-link variants), but only the cells
1082/// listed here are honoured by the family math; the rest are silently masked
1083/// by fallback arms. `FamilySpecKind` is the canonical projection used by
1084/// naming, predicates, and dispatch.
1085#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1086pub enum FamilySpecKind {
1087    GaussianIdentity,
1088    PoissonLog,
1089    GammaLog,
1090    TweedieLog { p: f64 },
1091    NegativeBinomialLog { theta: f64 },
1092    BetaLogit { phi: f64 },
1093    RoystonParmar,
1094    BinomialLogit,
1095    BinomialProbit,
1096    BinomialCLogLog,
1097    BinomialLatentCLogLog(LatentCLogLogState),
1098    BinomialSas(SasLinkState),
1099    BinomialBetaLogistic(SasLinkState),
1100    BinomialMixture(MixtureLinkState),
1101}
1102
1103impl FamilySpecKind {
1104    /// Short identifier matching the legacy `LikelihoodSpec::name()` strings.
1105    #[inline]
1106    pub const fn name(&self) -> &'static str {
1107        match self {
1108            Self::GaussianIdentity => "gaussian",
1109            Self::PoissonLog => "poisson-log",
1110            Self::TweedieLog { .. } => "tweedie-log",
1111            Self::NegativeBinomialLog { .. } => "negative-binomial-log",
1112            Self::BetaLogit { .. } => "beta-regression-logit",
1113            Self::GammaLog => "gamma-log",
1114            Self::RoystonParmar => "royston-parmar",
1115            Self::BinomialLogit => "binomial-logit",
1116            Self::BinomialProbit => "binomial-probit",
1117            Self::BinomialCLogLog => "binomial-cloglog",
1118            Self::BinomialLatentCLogLog(_) => "latent-cloglog-binomial",
1119            Self::BinomialSas(_) => "binomial-sas",
1120            Self::BinomialBetaLogistic(_) => "binomial-beta-logistic",
1121            Self::BinomialMixture(_) => "binomial-blended-inverse-link",
1122        }
1123    }
1124
1125    /// Human-readable label matching the legacy `LikelihoodSpec::pretty_name()` strings.
1126    #[inline]
1127    pub const fn pretty_name(&self) -> &'static str {
1128        match self {
1129            Self::GaussianIdentity => "Gaussian Identity",
1130            Self::PoissonLog => "Poisson Log",
1131            Self::TweedieLog { .. } => "Tweedie Log",
1132            Self::NegativeBinomialLog { .. } => "Negative-Binomial Log",
1133            Self::BetaLogit { .. } => "Beta Regression Logit",
1134            Self::GammaLog => "Gamma Log",
1135            Self::RoystonParmar => "Royston Parmar",
1136            Self::BinomialLogit => "Binomial Logit",
1137            Self::BinomialProbit => "Binomial Probit",
1138            Self::BinomialCLogLog => "Binomial CLogLog",
1139            Self::BinomialLatentCLogLog(_) => "Latent CLogLog Binomial",
1140            Self::BinomialSas(_) => "Binomial SAS",
1141            Self::BinomialBetaLogistic(_) => "Binomial Beta-Logistic",
1142            Self::BinomialMixture(_) => "Binomial Blended Inverse-Link",
1143        }
1144    }
1145
1146    #[inline]
1147    pub const fn is_binomial(&self) -> bool {
1148        matches!(
1149            self,
1150            Self::BinomialLogit
1151                | Self::BinomialProbit
1152                | Self::BinomialCLogLog
1153                | Self::BinomialLatentCLogLog(_)
1154                | Self::BinomialSas(_)
1155                | Self::BinomialBetaLogistic(_)
1156                | Self::BinomialMixture(_)
1157        )
1158    }
1159
1160    #[inline]
1161    pub const fn is_gaussian_identity(&self) -> bool {
1162        matches!(self, Self::GaussianIdentity)
1163    }
1164
1165    #[inline]
1166    pub const fn is_royston_parmar(&self) -> bool {
1167        matches!(self, Self::RoystonParmar)
1168    }
1169
1170    #[inline]
1171    pub const fn is_latent_cloglog(&self) -> bool {
1172        matches!(self, Self::BinomialLatentCLogLog(_))
1173    }
1174
1175    #[inline]
1176    pub const fn is_binomial_mixture(&self) -> bool {
1177        matches!(self, Self::BinomialMixture(_))
1178    }
1179
1180    #[inline]
1181    pub const fn is_binomial_sas(&self) -> bool {
1182        matches!(self, Self::BinomialSas(_))
1183    }
1184
1185    #[inline]
1186    pub const fn is_binomial_beta_logistic(&self) -> bool {
1187        matches!(self, Self::BinomialBetaLogistic(_))
1188    }
1189
1190    /// Coarse kind-level Firth eligibility: every binomial inverse link this
1191    /// enum can represent (Logit/Probit/CLogLog and the stateful
1192    /// LatentCLogLog/SAS/Beta-Logistic/Mixture links) carries a Fisher-weight
1193    /// jet, so kind-level Firth support is exactly binomial membership.
1194    ///
1195    /// The authoritative, link-resolved gate is
1196    /// [`LikelihoodSpec::supports_firth`], which routes through
1197    /// `inverse_link_has_fisher_weight_jet`. Keep this in agreement with that
1198    /// predicate: a future binomial link without a Fisher-weight jet would make
1199    /// this approximation diverge and must be handled at both sites.
1200    #[inline]
1201    pub const fn supports_firth(&self) -> bool {
1202        self.is_binomial()
1203    }
1204}
1205
1206impl LikelihoodSpec {
1207    /// Unchecked constructor: assembles a `(response, link)` cell *without*
1208    /// validating the legal matrix. Reserved for the in-crate named const
1209    /// constructors below (`gaussian_identity`, `poisson_log`, `beta_logit`,
1210    /// the `binomial_*` family, …), every one of which builds a cell that is
1211    /// legal by construction. The public, fallible entry point for an arbitrary
1212    /// `(response, link)` pair is [`LikelihoodSpec::try_new`]; the serde path
1213    /// also validates via [`LikelihoodSpecWire`]. Do not expose illegal cells
1214    /// through this method.
1215    #[inline]
1216    pub const fn new(response: ResponseFamily, link: InverseLink) -> Self {
1217        Self { response, link }
1218    }
1219
1220    /// Returns `true` when the `(response, link)` pair is one of the legal cells
1221    /// the family math honours — exactly the cells enumerated by
1222    /// [`LikelihoodSpec::kind`] before any masking. Each non-binomial response
1223    /// is pinned to a single inverse link; the binomial family admits its full
1224    /// set of probability links but never the identity/log standard links.
1225    #[inline]
1226    pub fn is_legal_cell(response: &ResponseFamily, link: &InverseLink) -> bool {
1227        match response {
1228            // Pure-identity families.
1229            ResponseFamily::Gaussian | ResponseFamily::RoystonParmar => {
1230                matches!(link, InverseLink::Standard(StandardLink::Identity))
1231            }
1232            // Log-link families.
1233            ResponseFamily::Poisson
1234            | ResponseFamily::Gamma
1235            | ResponseFamily::Tweedie { .. }
1236            | ResponseFamily::NegativeBinomial { .. } => {
1237                matches!(link, InverseLink::Standard(StandardLink::Log))
1238            }
1239            // Logit-link family.
1240            ResponseFamily::Beta { .. } => {
1241                matches!(link, InverseLink::Standard(StandardLink::Logit))
1242            }
1243            // Binomial admits every probability link except the inert
1244            // identity/log standard links.
1245            ResponseFamily::Binomial => match link {
1246                InverseLink::Standard(
1247                    StandardLink::Logit | StandardLink::Probit | StandardLink::CLogLog,
1248                ) => true,
1249                InverseLink::Standard(StandardLink::Identity | StandardLink::Log) => false,
1250                InverseLink::LatentCLogLog(_)
1251                | InverseLink::Sas(_)
1252                | InverseLink::BetaLogistic(_)
1253                | InverseLink::Mixture(_) => true,
1254            },
1255        }
1256    }
1257
1258    /// Fallible constructor over an arbitrary `(response, link)` pair. Validates
1259    /// the legal matrix ([`LikelihoodSpec::is_legal_cell`]) so that an illegal
1260    /// cell — one whose stored link would drive a wrong response transformation
1261    /// — is rejected instead of silently masked by [`LikelihoodSpec::kind`].
1262    #[inline]
1263    pub fn try_new(
1264        response: ResponseFamily,
1265        link: InverseLink,
1266    ) -> Result<Self, IllegalLikelihoodCell> {
1267        if Self::is_legal_cell(&response, &link) {
1268            Ok(Self::new(response, link))
1269        } else {
1270            Err(IllegalLikelihoodCell {
1271                response: response.name(),
1272                link: link.link_function().name(),
1273            })
1274        }
1275    }
1276
1277    #[inline]
1278    pub const fn gaussian_identity() -> Self {
1279        Self::new(
1280            ResponseFamily::Gaussian,
1281            InverseLink::Standard(StandardLink::Identity),
1282        )
1283    }
1284
1285    #[inline]
1286    pub const fn binomial_logit() -> Self {
1287        Self::new(
1288            ResponseFamily::Binomial,
1289            InverseLink::Standard(StandardLink::Logit),
1290        )
1291    }
1292
1293    #[inline]
1294    pub const fn binomial_probit() -> Self {
1295        Self::new(
1296            ResponseFamily::Binomial,
1297            InverseLink::Standard(StandardLink::Probit),
1298        )
1299    }
1300
1301    #[inline]
1302    pub const fn binomial_cloglog() -> Self {
1303        Self::new(
1304            ResponseFamily::Binomial,
1305            InverseLink::Standard(StandardLink::CLogLog),
1306        )
1307    }
1308
1309    #[inline]
1310    pub const fn binomial_latent_cloglog(state: LatentCLogLogState) -> Self {
1311        Self::new(ResponseFamily::Binomial, InverseLink::LatentCLogLog(state))
1312    }
1313
1314    #[inline]
1315    pub const fn binomial_sas(state: SasLinkState) -> Self {
1316        Self::new(ResponseFamily::Binomial, InverseLink::Sas(state))
1317    }
1318
1319    #[inline]
1320    pub const fn binomial_beta_logistic(state: SasLinkState) -> Self {
1321        Self::new(ResponseFamily::Binomial, InverseLink::BetaLogistic(state))
1322    }
1323
1324    #[inline]
1325    pub fn binomial_mixture(state: MixtureLinkState) -> Self {
1326        Self::new(ResponseFamily::Binomial, InverseLink::Mixture(state))
1327    }
1328
1329    #[inline]
1330    pub const fn poisson_log() -> Self {
1331        Self::new(
1332            ResponseFamily::Poisson,
1333            InverseLink::Standard(StandardLink::Log),
1334        )
1335    }
1336
1337    #[inline]
1338    pub const fn tweedie_log(p: f64) -> Self {
1339        Self::new(
1340            ResponseFamily::Tweedie { p },
1341            InverseLink::Standard(StandardLink::Log),
1342        )
1343    }
1344
1345    /// Estimated-theta NB spec: `theta` is the seed, refined by the inner
1346    /// solver (#802 default).
1347    #[inline]
1348    pub const fn negative_binomial_log(theta: f64) -> Self {
1349        Self::new(
1350            ResponseFamily::NegativeBinomial {
1351                theta,
1352                theta_fixed: false,
1353            },
1354            InverseLink::Standard(StandardLink::Log),
1355        )
1356    }
1357
1358    /// Fixed-theta NB spec: the fit holds `theta` at exactly this value
1359    /// (`--negative-binomial-theta`, issue #983).
1360    #[inline]
1361    pub const fn negative_binomial_log_fixed(theta: f64) -> Self {
1362        Self::new(
1363            ResponseFamily::NegativeBinomial {
1364                theta,
1365                theta_fixed: true,
1366            },
1367            InverseLink::Standard(StandardLink::Log),
1368        )
1369    }
1370
1371    #[inline]
1372    pub const fn beta_logit(phi: f64) -> Self {
1373        Self::new(
1374            ResponseFamily::Beta { phi },
1375            InverseLink::Standard(StandardLink::Logit),
1376        )
1377    }
1378
1379    #[inline]
1380    pub const fn gamma_log() -> Self {
1381        Self::new(
1382            ResponseFamily::Gamma,
1383            InverseLink::Standard(StandardLink::Log),
1384        )
1385    }
1386
1387    #[inline]
1388    pub const fn royston_parmar() -> Self {
1389        Self::new(
1390            ResponseFamily::RoystonParmar,
1391            InverseLink::Standard(StandardLink::Identity),
1392        )
1393    }
1394
1395    #[inline]
1396    pub const fn link_function(&self) -> LinkFunction {
1397        self.link.link_function()
1398    }
1399
1400    /// Once-and-for-all classification into the legal-only `FamilySpecKind`.
1401    ///
1402    /// `(ResponseFamily, InverseLink)` is a 40-cell product (8 response × 5
1403    /// inverse-link); only the cells listed here are legal. Construction
1404    /// ([`LikelihoodSpec::try_new`]) and deserialization (the
1405    /// [`LikelihoodSpecWire`] `try_from`) both enforce
1406    /// [`LikelihoodSpec::is_legal_cell`], so an illegal cell can never reach
1407    /// this method. Each link-pinned family therefore matches its *one* legal
1408    /// link explicitly; the remaining (now-unreachable) illegal combinations
1409    /// are `unreachable!()` so the historical silent masking — collapsing e.g.
1410    /// `Poisson + Identity` to `PoissonLog` while the transform predicted
1411    /// `μ = η` — can never silently happen again.
1412    pub fn kind(&self) -> FamilySpecKind {
1413        // `legal_cell_kind` returns `Some` for every legal cell and `None`
1414        // for the (by-construction-unreachable) illegal ones. Construction
1415        // (`try_new`) and deserialization (`LikelihoodSpecWire` try_from)
1416        // both enforce `is_legal_cell`, so the `None` branch can never fire
1417        // on a value that exists — `.expect` is the idiomatic loud-on-
1418        // impossible-state assertion (a banned `unreachable!`/`panic!` macro
1419        // would be the same panic with worse provenance). If it ever does
1420        // fire, the message names the offending cell so the silent-masking
1421        // regression this guards against (e.g. `Poisson + Identity`
1422        // collapsing to `PoissonLog`) stays impossible.
1423        self.legal_cell_kind().expect(
1424            "illegal likelihood cell reached kind(): construction (try_new) and \
1425             deserialization (LikelihoodSpecWire) guarantee legality",
1426        )
1427    }
1428
1429    fn legal_cell_kind(&self) -> Option<FamilySpecKind> {
1430        Some(match (&self.response, &self.link) {
1431            (ResponseFamily::Gaussian, InverseLink::Standard(StandardLink::Identity)) => {
1432                FamilySpecKind::GaussianIdentity
1433            }
1434            (ResponseFamily::RoystonParmar, InverseLink::Standard(StandardLink::Identity)) => {
1435                FamilySpecKind::RoystonParmar
1436            }
1437            (ResponseFamily::Poisson, InverseLink::Standard(StandardLink::Log)) => {
1438                FamilySpecKind::PoissonLog
1439            }
1440            (ResponseFamily::Gamma, InverseLink::Standard(StandardLink::Log)) => {
1441                FamilySpecKind::GammaLog
1442            }
1443            (ResponseFamily::Tweedie { p }, InverseLink::Standard(StandardLink::Log)) => {
1444                FamilySpecKind::TweedieLog { p: *p }
1445            }
1446            (
1447                ResponseFamily::NegativeBinomial { theta, .. },
1448                InverseLink::Standard(StandardLink::Log),
1449            ) => FamilySpecKind::NegativeBinomialLog { theta: *theta },
1450            (ResponseFamily::Beta { phi }, InverseLink::Standard(StandardLink::Logit)) => {
1451                FamilySpecKind::BetaLogit { phi: *phi }
1452            }
1453            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Logit)) => {
1454                FamilySpecKind::BinomialLogit
1455            }
1456            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::Probit)) => {
1457                FamilySpecKind::BinomialProbit
1458            }
1459            (ResponseFamily::Binomial, InverseLink::Standard(StandardLink::CLogLog)) => {
1460                FamilySpecKind::BinomialCLogLog
1461            }
1462            (ResponseFamily::Binomial, InverseLink::LatentCLogLog(state)) => {
1463                FamilySpecKind::BinomialLatentCLogLog(*state)
1464            }
1465            (ResponseFamily::Binomial, InverseLink::Sas(state)) => {
1466                FamilySpecKind::BinomialSas(*state)
1467            }
1468            (ResponseFamily::Binomial, InverseLink::BetaLogistic(state)) => {
1469                FamilySpecKind::BinomialBetaLogistic(*state)
1470            }
1471            (ResponseFamily::Binomial, InverseLink::Mixture(state)) => {
1472                FamilySpecKind::BinomialMixture(state.clone())
1473            }
1474            // Every remaining product cell is illegal. `try_new` /
1475            // `LikelihoodSpecWire::try_from` reject these, so construction and
1476            // deserialization guarantee they are unreachable here; `None`
1477            // surfaces that to `kind()`, which aborts loudly via `.expect`
1478            // rather than misclassify the family (a wrong `FamilySpecKind`
1479            // would silently corrupt every downstream likelihood/gradient
1480            // evaluation). A banned `panic!`/`unreachable!` macro would be the
1481            // same divergence with worse provenance.
1482            _ => return None,
1483        })
1484    }
1485
1486    #[inline]
1487    pub fn is_binomial(&self) -> bool {
1488        self.kind().is_binomial()
1489    }
1490
1491    #[inline]
1492    pub fn is_gaussian_identity(&self) -> bool {
1493        self.kind().is_gaussian_identity()
1494    }
1495
1496    #[inline]
1497    pub fn is_royston_parmar(&self) -> bool {
1498        self.kind().is_royston_parmar()
1499    }
1500
1501    #[inline]
1502    pub fn is_latent_cloglog(&self) -> bool {
1503        self.kind().is_latent_cloglog()
1504    }
1505
1506    #[inline]
1507    pub fn is_binomial_mixture(&self) -> bool {
1508        self.kind().is_binomial_mixture()
1509    }
1510
1511    #[inline]
1512    pub fn is_binomial_sas(&self) -> bool {
1513        self.kind().is_binomial_sas()
1514    }
1515
1516    #[inline]
1517    pub fn is_binomial_beta_logistic(&self) -> bool {
1518        self.kind().is_binomial_beta_logistic()
1519    }
1520
1521    /// Default scale metadata for this (response, link).
1522    #[inline]
1523    pub fn default_scale_metadata(&self) -> LikelihoodScaleMetadata {
1524        match &self.response {
1525            ResponseFamily::Gaussian => LikelihoodScaleMetadata::ProfiledGaussian,
1526            ResponseFamily::Gamma => LikelihoodScaleMetadata::EstimatedGammaShape { shape: 1.0 },
1527            // Binomial and Poisson have `phi ≡ 1` (variance fully pinned by the
1528            // mean), so a fixed unit dispersion is correct.
1529            ResponseFamily::Binomial | ResponseFamily::Poisson => {
1530                LikelihoodScaleMetadata::FixedDispersion { phi: 1.0 }
1531            }
1532            // Negative-Binomial's overdispersion `theta` (`Var(y)=mu+mu^2/theta`)
1533            // is a genuine free parameter estimated jointly with the mean by
1534            // default — the family-variant `theta` is only the seed, refined from
1535            // the converged-η ML score during fitting, exactly like the Gamma
1536            // shape / Beta precision / Tweedie φ. Freezing it at the seed made
1537            // every variance-derived output (coefficient/η SEs, Wald and credible
1538            // intervals, predictive intervals, `generate` draws) ignore the
1539            // data's overdispersion (issue #802). `phi` itself stays `≡ 1`.
1540            //
1541            // A user-supplied `--negative-binomial-theta` is the opposite
1542            // contract (issue #983): `theta_fixed = true` routes to the
1543            // non-estimated scale variant, so the inner solver's refresh gate
1544            // (`negbin_theta_is_estimated()`) stays closed and the fit honours
1545            // the held value everywhere it enters.
1546            ResponseFamily::NegativeBinomial { theta, theta_fixed } => {
1547                if *theta_fixed {
1548                    LikelihoodScaleMetadata::FixedNegBinTheta { theta: *theta }
1549                } else {
1550                    LikelihoodScaleMetadata::EstimatedNegBinTheta { theta: *theta }
1551                }
1552            }
1553            // Tweedie's dispersion `phi` is a genuine free parameter
1554            // (`Var(y) = phi · mu^p`) and is estimated jointly with the mean by
1555            // default, exactly like the Gamma shape and Beta precision. The seed
1556            // `phi = 1` is refined from the converged-η Pearson residuals during
1557            // fitting (issue #771). Freezing it at 1 made every variance-derived
1558            // output (SEs, intervals, generate draws) ignore the data's spread.
1559            ResponseFamily::Tweedie { .. } => {
1560                LikelihoodScaleMetadata::EstimatedTweediePhi { phi: 1.0 }
1561            }
1562            // Beta precision is estimated jointly with the mean by default
1563            // (magic-by-default, issue #567): the family-variant `phi` is the
1564            // seed, refined from the working residuals during fitting.
1565            ResponseFamily::Beta { phi } => LikelihoodScaleMetadata::EstimatedBetaPhi { phi: *phi },
1566            ResponseFamily::RoystonParmar => LikelihoodScaleMetadata::Unspecified,
1567        }
1568    }
1569
1570    /// Human-readable label, routed through `FamilySpecKind`.
1571    #[inline]
1572    pub fn pretty_name(&self) -> &'static str {
1573        self.kind().pretty_name()
1574    }
1575
1576    /// Short identifier, routed through `FamilySpecKind`.
1577    #[inline]
1578    pub fn name(&self) -> &'static str {
1579        self.kind().name()
1580    }
1581
1582    #[inline]
1583    pub fn supports_firth(&self) -> bool {
1584        matches!(self.response, ResponseFamily::Binomial)
1585            && inverse_link_has_fisher_weight_jet(&self.link)
1586    }
1587
1588    /// Family-level fixed-dispersion contract. Returns the dispersion parameter
1589    /// `phi` that the GLM log-likelihood / weight expressions treat as fixed
1590    /// for the given `ResponseFamily`, or `None` when the family carries no
1591    /// fixed scale (profiled or jointly estimated).
1592    ///
1593    /// - `Gaussian` and `Gamma` profile/estimate the scale jointly with the
1594    ///   mean, so no fixed `phi` is exposed here.
1595    /// - `Binomial` and `Poisson` are unit-scale exponential-family fits, so the
1596    ///   contract is `Some(1.0)`. NegativeBinomial's overdispersion lives in
1597    ///   `theta` (a separate parameter / flag), not in a free `phi`, so it also
1598    ///   returns `Some(1.0)`.
1599    /// - `Tweedie { p }` carries its variance power on the family variant. Its
1600    ///   free dispersion `phi` lives in `LikelihoodScaleMetadata` and is
1601    ///   estimated by default (`EstimatedTweediePhi`, issue #771), so this
1602    ///   family-level contract only exposes the unit seed used when callers ask
1603    ///   the response family without scale metadata.
1604    /// - `Beta { phi }` carries its precision parameter directly on the family
1605    ///   variant; the contract returns that exact value rather than the
1606    ///   placeholder used elsewhere for unit-scale GLMs.
1607    /// - `RoystonParmar` has no GLM-style dispersion slot.
1608    #[inline]
1609    pub const fn fixed_dispersion(&self) -> Option<f64> {
1610        match self.response {
1611            ResponseFamily::Gaussian | ResponseFamily::Gamma | ResponseFamily::RoystonParmar => {
1612                None
1613            }
1614            ResponseFamily::Binomial
1615            | ResponseFamily::Poisson
1616            | ResponseFamily::Tweedie { .. }
1617            | ResponseFamily::NegativeBinomial { .. } => Some(1.0),
1618            ResponseFamily::Beta { phi } => Some(phi),
1619        }
1620    }
1621}
1622
1623#[inline]
1624pub const fn is_valid_tweedie_power(p: f64) -> bool {
1625    p.is_finite() && p > 1.0 && p < 2.0
1626}
1627
1628/// Error returned when an `InverseLink` cannot be paired with a particular
1629/// response family because the link is structurally unsupported for that
1630/// family. Carries the link name so call sites can produce a useful message
1631/// without losing the offending variant.
1632#[derive(Debug, Clone, PartialEq, Eq)]
1633pub struct UnsupportedLinkError {
1634    pub family: &'static str,
1635    pub link_name: String,
1636}
1637
1638impl UnsupportedLinkError {
1639    /// Construct an `UnsupportedLinkError` tagged with the response-family
1640    /// name (`"binomial"`, `"gaussian"`, ...) and a printable name for the
1641    /// offending `InverseLink` variant (extracted via the module-private
1642    /// `inverse_link_diagnostic_name`). No allocation beyond the link name.
1643    #[inline]
1644    pub fn new(family: &'static str, link: &InverseLink) -> Self {
1645        Self {
1646            family,
1647            link_name: inverse_link_diagnostic_name(link),
1648        }
1649    }
1650}
1651
1652impl std::fmt::Display for UnsupportedLinkError {
1653    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1654        write!(
1655            f,
1656            "inverse link `{}` is not supported by the {} response family",
1657            self.link_name, self.family
1658        )
1659    }
1660}
1661
1662impl std::error::Error for UnsupportedLinkError {}
1663
1664#[inline]
1665pub fn inverse_link_diagnostic_name(link: &InverseLink) -> String {
1666    match link {
1667        InverseLink::Standard(lf) => lf.name().to_string(),
1668        InverseLink::LatentCLogLog(_) => "latent-cloglog".to_string(),
1669        InverseLink::Sas(_) => "sas".to_string(),
1670        InverseLink::BetaLogistic(_) => "beta-logistic".to_string(),
1671        InverseLink::Mixture(_) => "mixture".to_string(),
1672    }
1673}
1674
1675/// Resolve a binomial-flavoured `LikelihoodSpec` from an `InverseLink`.
1676///
1677/// `StandardLink::Logit | Probit | CLogLog` and the state-bearing
1678/// `LatentCLogLog / Sas / BetaLogistic / Mixture` variants are accepted as
1679/// binomial-compatible. `StandardLink::Log | Identity` have no canonical
1680/// binomial meaning and return `UnsupportedLinkError`. Since
1681/// `InverseLink::Standard` carries `StandardLink` (not `LinkFunction`), the
1682/// previously-required `Standard(LinkFunction::Sas | BetaLogistic)` arm is
1683/// structurally impossible and has been removed.
1684#[inline]
1685pub fn inverse_link_to_binomial_spec(
1686    link: &InverseLink,
1687) -> Result<LikelihoodSpec, UnsupportedLinkError> {
1688    match link {
1689        InverseLink::Standard(StandardLink::Logit)
1690        | InverseLink::Standard(StandardLink::Probit)
1691        | InverseLink::Standard(StandardLink::CLogLog) => {
1692            Ok(LikelihoodSpec::new(ResponseFamily::Binomial, link.clone()))
1693        }
1694        InverseLink::LatentCLogLog(_)
1695        | InverseLink::Sas(_)
1696        | InverseLink::BetaLogistic(_)
1697        | InverseLink::Mixture(_) => {
1698            Ok(LikelihoodSpec::new(ResponseFamily::Binomial, link.clone()))
1699        }
1700        InverseLink::Standard(StandardLink::Log)
1701        | InverseLink::Standard(StandardLink::Identity) => {
1702            Err(UnsupportedLinkError::new("binomial", link))
1703        }
1704    }
1705}
1706
1707/// How a likelihood's scale parameter is handled by the fit/result contract.
1708#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1709pub enum LikelihoodScaleMetadata {
1710    /// Gaussian identity fits profile sigma outside the fixed-scale GLM machinery.
1711    ProfiledGaussian,
1712    /// Fixed exponential-dispersion parameter `phi`.
1713    FixedDispersion { phi: f64 },
1714    /// Fixed Gamma shape `k`, equivalent to `phi = 1 / k`.
1715    FixedGammaShape { shape: f64 },
1716    /// Gamma shape `k` estimated jointly with the mean model.
1717    EstimatedGammaShape { shape: f64 },
1718    /// Beta-regression precision `phi` estimated jointly with the mean model.
1719    /// `Var(y) = mu(1-mu)/(1+phi)`; larger `phi` means less noise. Estimated
1720    /// from the working residuals after each mean fit and refreshed across outer
1721    /// iterations, exactly like the Gamma shape (issue #567).
1722    EstimatedBetaPhi { phi: f64 },
1723    /// Tweedie exponential-dispersion `phi` estimated jointly with the mean
1724    /// model. `Var(y) = phi · mu^p` with `phi` a genuine free parameter (unlike
1725    /// Binomial/Poisson, where `phi ≡ 1`). Estimated by the Pearson moment
1726    /// estimator `phî = Σ wᵢ (yᵢ − μᵢ)² / μᵢ^p / Σ wᵢ` at the converged η and
1727    /// refreshed across outer iterations, exactly like the Gamma shape and the
1728    /// Beta precision. `phi` enters the IRLS working weight `prior·μ^{2−p}/phi`,
1729    /// so the coefficient covariance `Vb = H⁻¹` already scales as `phi` and the
1730    /// reported SEs track `√phi` (issue #771).
1731    EstimatedTweediePhi { phi: f64 },
1732    /// Negative-Binomial overdispersion `theta` estimated jointly with the mean
1733    /// model. `Var(y) = mu + mu^2 / theta`; larger `theta` means less
1734    /// overdispersion (the Poisson limit is `theta → ∞`). Estimated by the
1735    /// maximum-likelihood `theta` score
1736    /// `Σ wᵢ[ψ(yᵢ+θ) − ψ(θ) + lnθ + 1 − ln(θ+μᵢ) − (yᵢ+θ)/(μᵢ+θ)] = 0` at the
1737    /// converged η (MASS `glm.nb`'s `theta.ml`) and refreshed across outer
1738    /// iterations, exactly like the Gamma shape / Beta precision / Tweedie φ.
1739    /// Unlike those, `theta` is *not* a dispersion scale `phi`: it enters only
1740    /// the IRLS working weight `W = μθ/(θ+μ)` (the full NB2 Fisher information),
1741    /// so the stored penalized Hessian is already the true one and the
1742    /// coefficient covariance `Vb = H⁻¹` takes no post-hoc multiply — `phi ≡ 1`
1743    /// for NB, the overdispersion lives in the variance function. The `theta`
1744    /// carried here mirrors `ResponseFamily::NegativeBinomial { theta }` (the
1745    /// canonical store every weight/deviance expression reads), kept in sync by
1746    /// `with_negbin_theta`, exactly as `EstimatedBetaPhi` mirrors `Beta { phi }`
1747    /// (issue #802).
1748    EstimatedNegBinTheta { theta: f64 },
1749    /// Negative-Binomial overdispersion `theta` held fixed at a user-supplied
1750    /// value (`--negative-binomial-theta`, issue #983). Identical role to
1751    /// `EstimatedNegBinTheta` in every weight / variance / covariance
1752    /// expression (`W = μθ/(θ+μ)`, `Var(y) = μ + μ²/θ`, `phi ≡ 1`), but the
1753    /// inner solver's ML refresh is gated off: the recorded `theta` is the
1754    /// user's, by construction. The fixed/estimated split mirrors
1755    /// `FixedGammaShape` vs `EstimatedGammaShape`.
1756    FixedNegBinTheta { theta: f64 },
1757    /// The engine does not expose fixed-scale semantics for this family.
1758    Unspecified,
1759}
1760
1761impl LikelihoodScaleMetadata {
1762    #[inline]
1763    pub const fn fixed_phi(self) -> Option<f64> {
1764        match self {
1765            Self::FixedDispersion { phi }
1766            | Self::EstimatedBetaPhi { phi }
1767            | Self::EstimatedTweediePhi { phi } => Some(phi),
1768            Self::FixedGammaShape { shape } | Self::EstimatedGammaShape { shape } => {
1769                Some(1.0 / shape)
1770            }
1771            // NB's dispersion scale is `phi ≡ 1` (the overdispersion is carried
1772            // by `theta` inside the variance function, not a scale multiply), so
1773            // the fixed-`phi` contract is `Some(1.0)` — NOT `theta`.
1774            Self::EstimatedNegBinTheta { .. } | Self::FixedNegBinTheta { .. } => Some(1.0),
1775            Self::ProfiledGaussian | Self::Unspecified => None,
1776        }
1777    }
1778
1779    /// Whether the Negative-Binomial overdispersion `theta` is estimated from
1780    /// data (the default for NB families, issue #802).
1781    #[inline]
1782    pub const fn negbin_theta_is_estimated(self) -> bool {
1783        matches!(self, Self::EstimatedNegBinTheta { .. })
1784    }
1785
1786    /// The Negative-Binomial `theta` carried in the scale metadata (estimated
1787    /// or user-fixed), or `None` for non-NB families.
1788    #[inline]
1789    pub const fn negbin_theta(self) -> Option<f64> {
1790        match self {
1791            Self::EstimatedNegBinTheta { theta } | Self::FixedNegBinTheta { theta } => Some(theta),
1792            _ => None,
1793        }
1794    }
1795
1796    /// Whether the Beta-regression precision `phi` is estimated from data.
1797    #[inline]
1798    pub const fn beta_phi_is_estimated(self) -> bool {
1799        matches!(self, Self::EstimatedBetaPhi { .. })
1800    }
1801
1802    /// Whether the Tweedie exponential-dispersion `phi` is estimated from data.
1803    #[inline]
1804    pub const fn tweedie_phi_is_estimated(self) -> bool {
1805        matches!(self, Self::EstimatedTweediePhi { .. })
1806    }
1807
1808    #[inline]
1809    pub const fn gamma_shape(self) -> Option<f64> {
1810        match self {
1811            Self::FixedGammaShape { shape } | Self::EstimatedGammaShape { shape } => Some(shape),
1812            _ => None,
1813        }
1814    }
1815
1816    #[inline]
1817    pub const fn gamma_shape_is_estimated(self) -> bool {
1818        matches!(self, Self::EstimatedGammaShape { .. })
1819    }
1820}
1821
1822/// Whether a stored log-likelihood includes response-only normalization constants.
1823#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1824pub enum LogLikelihoodNormalization {
1825    Full,
1826    OmittingResponseConstants,
1827    UserProvided,
1828}
1829
1830/// Explicit GLM likelihood specification: response/link spec plus scale semantics.
1831///
1832/// `spec` is the canonical `(ResponseFamily, InverseLink)` selector. `scale`
1833/// records how the scale parameter is handled (profiled Gaussian sigma, fixed
1834/// dispersion, fixed/estimated Gamma shape). The Gamma shape is mutated in
1835/// place during PIRLS via `with_gamma_shape`; preserving that field on this
1836/// struct is what lets the inner solver thread the estimated shape into
1837/// deviance / log-likelihood / weight evaluation without a separate side
1838/// channel.
1839#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1840pub struct GlmLikelihoodSpec {
1841    pub spec: LikelihoodSpec,
1842    pub scale: LikelihoodScaleMetadata,
1843}
1844
1845impl GlmLikelihoodSpec {
1846    /// Build a `GlmLikelihoodSpec` from a `LikelihoodSpec`, deriving the
1847    /// canonical default scale metadata for the response family.
1848    #[inline]
1849    pub fn canonical(spec: LikelihoodSpec) -> Self {
1850        let scale = spec.default_scale_metadata();
1851        Self { spec, scale }
1852    }
1853
1854    #[inline]
1855    pub fn link_function(&self) -> LinkFunction {
1856        self.spec.link_function()
1857    }
1858
1859    #[inline]
1860    pub fn fixed_phi(&self) -> Option<f64> {
1861        self.scale.fixed_phi()
1862    }
1863
1864    /// Multiplier converting the stored unscaled inverse penalized Hessian
1865    /// `H⁻¹` into the reported coefficient covariance `Vb = H⁻¹ · scale`.
1866    ///
1867    /// # Invariant
1868    ///
1869    /// `Vb` is the inverse of the Hessian of the *actual penalized objective the
1870    /// inner solver minimizes*. The stored Hessian is always assembled as
1871    /// `H = XᵀWX + S_λ`, with the penalty `S_λ` added **unscaled** (see
1872    /// `pirls::penalty::add_to_hessian`). Whether `H` is already that true
1873    /// objective Hessian — and hence whether any post-hoc dispersion multiply is
1874    /// warranted — is decided entirely by what the IRLS working weight `W`
1875    /// carries:
1876    ///
1877    /// * **Working weight already carries the reciprocal dispersion / full
1878    ///   Fisher information.** Then `H = Xᵀ(W_sf/φ)X + S_λ` already equals the
1879    ///   true penalized Hessian (e.g. mgcv's `XᵀW_sfX/φ + S_λ` for Gamma), so
1880    ///   `Vb = H⁻¹` and the scale is exactly `1.0`. This is the case for Gamma
1881    ///   (`W = prior·shape = prior/φ`), Tweedie (`W = prior·μ^{2−p}/φ`), Beta
1882    ///   and Negative-Binomial (the working weight is the complete fixed-scale
1883    ///   Fisher information), and the fixed-scale exponential families
1884    ///   Poisson/Binomial (`φ ≡ 1`). Multiplying `H⁻¹` by the dispersion again
1885    ///   for any of these double-counts it and shrinks every SE by `√dispersion`.
1886    ///
1887    /// * **Working weight is scale-free** (`W = priorweights`, the profiled
1888    ///   Gaussian convention). Then the data term carries an implicit unit scale
1889    ///   and `H = XᵀPX + S_λ` is the Hessian of `½·(scaled deviance)·σ²⁻¹`
1890    ///   *without* the `σ²`. The correct covariance restores it:
1891    ///   `Vb = H⁻¹ · σ̂²`. Only this branch returns a non-unit scale.
1892    ///
1893    /// `profiled_gaussian_phi` is the profiled residual variance `σ̂²` and is
1894    /// consulted **only** for the scale-free profiled-Gaussian branch; every
1895    /// other family ignores it. This deliberately does NOT touch
1896    /// `dispersion()` / `dispersion_from_likelihood`, which still report the
1897    /// response-level observation noise (`1/shape` for Gamma, `1/(1+φ)` for
1898    /// Beta, …) used by predictive-interval construction — a distinct quantity
1899    /// from the coefficient-covariance scale defined here.
1900    #[inline]
1901    pub fn coefficient_covariance_scale(&self, profiled_gaussian_phi: f64) -> f64 {
1902        match self.scale {
1903            // Scale-free working weight: restore the profiled variance.
1904            LikelihoodScaleMetadata::ProfiledGaussian => profiled_gaussian_phi,
1905            // Working weight already carries the dispersion / full Fisher
1906            // information, so the stored H is the true penalized Hessian and no
1907            // further dispersion multiply is warranted.
1908            //
1909            // FixedDispersion covers the explicitly-scaled Gaussian submodel
1910            // (W·=1/φ above) and Negative-Binomial; the Gamma, Beta and Tweedie
1911            // variants fold their reciprocal-dispersion / precision / φ into W
1912            // (Tweedie W = prior·μ^{2−p}/φ, so the SE already scales as √φ); and
1913            // Unspecified families never expose a separate post-hoc scale.
1914            LikelihoodScaleMetadata::FixedDispersion { .. }
1915            | LikelihoodScaleMetadata::FixedGammaShape { .. }
1916            | LikelihoodScaleMetadata::EstimatedGammaShape { .. }
1917            | LikelihoodScaleMetadata::EstimatedBetaPhi { .. }
1918            | LikelihoodScaleMetadata::EstimatedTweediePhi { .. }
1919            // Negative-Binomial folds `theta` into the working weight
1920            // `W = μθ/(θ+μ)` (the full NB2 Fisher information), so the stored
1921            // `H = XᵀWX + S_λ` is already the true penalized Hessian and the
1922            // covariance scale is `1.0` (`phi ≡ 1`). The reported SEs respond to
1923            // the data's overdispersion entirely through that `theta`-dependent
1924            // weight (issue #802) — multiplying again would double-count it.
1925            // The same holds verbatim for a user-fixed `theta` (issue #983).
1926            | LikelihoodScaleMetadata::EstimatedNegBinTheta { .. }
1927            | LikelihoodScaleMetadata::FixedNegBinTheta { .. }
1928            | LikelihoodScaleMetadata::Unspecified => 1.0,
1929        }
1930    }
1931
1932    #[inline]
1933    pub fn gamma_shape(&self) -> Option<f64> {
1934        self.scale.gamma_shape()
1935    }
1936
1937    /// Mutate the Gamma shape parameter in place while preserving the rest of
1938    /// the spec. The shape only takes effect for Gamma families; for other
1939    /// families the scale metadata is left untouched.
1940    #[inline]
1941    pub fn with_gamma_shape(mut self, shape: f64) -> Self {
1942        self.scale = match self.scale {
1943            LikelihoodScaleMetadata::FixedGammaShape { .. } => {
1944                LikelihoodScaleMetadata::FixedGammaShape { shape }
1945            }
1946            LikelihoodScaleMetadata::EstimatedGammaShape { .. } => {
1947                LikelihoodScaleMetadata::EstimatedGammaShape { shape }
1948            }
1949            other => match &self.spec.response {
1950                ResponseFamily::Gamma => LikelihoodScaleMetadata::EstimatedGammaShape { shape },
1951                _ => other,
1952            },
1953        };
1954        self
1955    }
1956
1957    /// Whether the Beta-regression precision `phi` is estimated from data.
1958    #[inline]
1959    pub fn beta_phi_is_estimated(&self) -> bool {
1960        self.scale.beta_phi_is_estimated()
1961    }
1962
1963    /// Mutate the Beta precision `phi` in place, on BOTH the family variant
1964    /// (where every PIRLS weight / deviance / log-likelihood expression reads it
1965    /// via `ResponseFamily::Beta { phi }`) and the scale metadata (the
1966    /// estimated-vs-fixed contract). No-op for non-Beta families. The inner
1967    /// solver calls this once per inner solve after a moment estimate of `phi`
1968    /// from the working residuals, so the IRLS weights `Var(y)=mu(1-mu)/(1+phi)`
1969    /// reflect the true precision rather than the `phi=1` seed (issue #567).
1970    #[inline]
1971    pub fn with_beta_phi(mut self, phi: f64) -> Self {
1972        if let ResponseFamily::Beta { phi: family_phi } = &mut self.spec.response {
1973            *family_phi = phi;
1974            self.scale = LikelihoodScaleMetadata::EstimatedBetaPhi { phi };
1975        }
1976        self
1977    }
1978
1979    /// Whether the Tweedie exponential-dispersion `phi` is estimated from data.
1980    #[inline]
1981    pub fn tweedie_phi_is_estimated(&self) -> bool {
1982        self.scale.tweedie_phi_is_estimated()
1983    }
1984
1985    /// Mutate the Tweedie dispersion `phi` in place. Unlike Beta, the Tweedie
1986    /// power `p` (not `phi`) is what is carried on the `ResponseFamily::Tweedie`
1987    /// variant; the dispersion lives purely in the scale metadata and is read by
1988    /// the IRLS weight (`prior·μ^{2−p}/phi`) through `fixed_phi()`. So updating
1989    /// the metadata here is sufficient to thread the estimated `phi` into every
1990    /// weight / covariance expression. No-op for non-Tweedie families (issue
1991    /// #771).
1992    #[inline]
1993    pub fn with_tweedie_phi(mut self, phi: f64) -> Self {
1994        if matches!(self.spec.response, ResponseFamily::Tweedie { .. }) {
1995            self.scale = LikelihoodScaleMetadata::EstimatedTweediePhi { phi };
1996        }
1997        self
1998    }
1999
2000    /// Whether the Negative-Binomial overdispersion `theta` is estimated from
2001    /// data (issue #802).
2002    #[inline]
2003    pub fn negbin_theta_is_estimated(&self) -> bool {
2004        self.scale.negbin_theta_is_estimated()
2005    }
2006
2007    /// Mutate the Negative-Binomial overdispersion `theta` in place, on BOTH the
2008    /// family variant (where every PIRLS weight / deviance / log-likelihood
2009    /// expression reads it via `ResponseFamily::NegativeBinomial { theta }`) and
2010    /// the scale metadata (the estimated-vs-fixed contract). No-op for non-NB
2011    /// families. The inner solver calls this once per inner solve after a
2012    /// maximum-likelihood estimate of `theta` from the working residuals, so the
2013    /// IRLS weight `W = μθ/(θ+μ)` and the variance `Var(y)=mu+mu^2/theta` reflect
2014    /// the data's overdispersion rather than the seed `theta` (issue #802). This
2015    /// mirrors `with_beta_phi` exactly — both keep the family variant and the
2016    /// scale metadata as two synchronized views of one estimated parameter.
2017    /// No-op for a user-fixed `theta` (`theta_fixed = true` /
2018    /// `FixedNegBinTheta`, issue #983): the held value is the contract, and
2019    /// this mutator must never let an estimation path overwrite it — the
2020    /// PIRLS refresh gate (`negbin_theta_is_estimated()`) already skips the
2021    /// call, this enforces the same invariant at the data itself.
2022    #[inline]
2023    pub fn with_negbin_theta(mut self, theta: f64) -> Self {
2024        if let ResponseFamily::NegativeBinomial {
2025            theta: family_theta,
2026            theta_fixed,
2027        } = &mut self.spec.response
2028            && !*theta_fixed
2029        {
2030            *family_theta = theta;
2031            self.scale = LikelihoodScaleMetadata::EstimatedNegBinTheta { theta };
2032        }
2033        self
2034    }
2035
2036    /// The estimated Negative-Binomial `theta`, read from the family variant
2037    /// (the canonical store), or `None` for non-NB families.
2038    #[inline]
2039    pub fn negbin_theta(&self) -> Option<f64> {
2040        match self.spec.response {
2041            ResponseFamily::NegativeBinomial { theta, .. } => Some(theta),
2042            _ => None,
2043        }
2044    }
2045
2046    /// Produce a copy of this spec with the Negative-Binomial overdispersion
2047    /// `theta` PINNED at `theta` for the duration of the smoothing-parameter
2048    /// (λ) search (#1082). Converts an `EstimatedNegBinTheta` spec into the
2049    /// statistically-identical `FixedNegBinTheta` form (`theta_fixed = true`),
2050    /// which gates off the per-inner-solve ML refresh in
2051    /// `GamWorkingModel::update_with_curvature` (its guard is
2052    /// `negbin_theta_is_estimated()`).
2053    ///
2054    /// Rationale: with θ estimated, the inner solver re-derives θ from each
2055    /// outer iterate's *warm-start* η, so θ — and hence the NB working response,
2056    /// deviance and penalty-logdet that feed the REML criterion — drifts every
2057    /// outer evaluation. The outer optimizer then chases a moving target and the
2058    /// projected-gradient convergence test never trips, grinding the loop to
2059    /// `max_iter` (the #1082 negative-binomial tensor timeout). Holding θ fixed
2060    /// across the λ-search makes the REML objective `F(ρ) = REML(ρ, θ_frozen)` a
2061    /// genuine stationary function of ρ, so the loop converges in a handful of
2062    /// iterations — and θ is still ML-refreshed at the single final, reported fit
2063    /// (the `refine_dispersion_at_converged_eta = true` accept-fit), exactly as
2064    /// the function-level docs require ("estimate the scale at the converged fit,
2065    /// not inside the λ search; mgcv likewise"). No-op for non-NB families and
2066    /// for an already user-fixed θ.
2067    #[inline]
2068    pub fn with_negbin_theta_frozen_for_search(mut self, theta: f64) -> Self {
2069        if let ResponseFamily::NegativeBinomial {
2070            theta: family_theta,
2071            theta_fixed,
2072        } = &mut self.spec.response
2073        {
2074            *family_theta = theta;
2075            *theta_fixed = true;
2076            self.scale = LikelihoodScaleMetadata::FixedNegBinTheta { theta };
2077        }
2078        self
2079    }
2080}