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