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