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