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