Skip to main content

gam_spec/
lib.rs

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