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