Skip to main content

gam_model_kernels/
sigma_link.rs

1use ndarray::{Array1, ArrayView1};
2
3#[derive(Clone, Copy, Debug, PartialEq)]
4pub struct SigmaJet1 {
5    pub sigma: f64,
6    pub d1: f64,
7}
8
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct SigmaJet3 {
11    pub sigma: f64,
12    pub d1: f64,
13    pub d2: f64,
14    pub d3: f64,
15}
16
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub(crate) struct SigmaJet4 {
19    pub sigma: f64,
20    pub d1: f64,
21    pub d2: f64,
22    pub d3: f64,
23    pub d4: f64,
24}
25
26/// Exact exponential link on the native `f64` range.
27///
28/// This matches `exp(eta)` itself: values remain finite throughout the true
29/// representable range, overflow to `+inf` only when `f64::exp` overflows, and
30/// underflow to `0.0` only when `f64::exp` underflows.
31#[inline]
32pub fn safe_exp(eta: f64) -> f64 {
33    eta.exp()
34}
35
36#[inline]
37pub fn exp_sigma_jet1_scalar(eta: f64) -> SigmaJet1 {
38    let sigma = safe_exp(eta);
39    SigmaJet1 { sigma, d1: sigma }
40}
41
42#[inline]
43pub fn exp_sigma_from_eta_scalar(eta: f64) -> f64 {
44    safe_exp(eta)
45}
46
47/// Largest exponent argument whose `exp` is still finite in binary64.
48///
49/// `ln(f64::MAX) ≈ 709.782712893384`; this constant sits ~1e-11 below it so
50/// `exp(EXP_SATURATION_MAX_ARG)` is guaranteed to round to a finite value
51/// (≈ `f64::MAX · (1 − 1.3e-11)`). The inverse σ-link saturates only here —
52/// at the representability boundary of the number format itself — so the
53/// implemented link equals the mathematical `exp(-η)` for every argument
54/// whose value is representable in `f64`, and the saturated value differs
55/// from the true value's rounding by less than one part in 1e11.
56///
57/// (A former cap at +500 silently rewrote *finite* models: `exp(600)` ≈
58/// 3.8e260 is perfectly representable but was returned as `exp(500)` ≈
59/// 1.4e217, desynchronizing the likelihood value from the uncapped
60/// gradient/Hessian algebra over the entire exponent band (500, 709.78].)
61pub const EXP_SATURATION_MAX_ARG: f64 = 709.78271289338;
62
63/// Overflow-safe `exp(-x)`: exact wherever `exp(-x)` is representable.
64///
65/// Saturates at `exp(EXP_SATURATION_MAX_ARG) ≈ f64::MAX` instead of
66/// overflowing to `+inf` (which would poison downstream products with NaN
67/// via `inf · 0`), and allows natural IEEE 754 underflow to `0.0` when `x`
68/// is very positive because that is the mathematically correct limit.
69///
70/// The one-sided guard is critical: for `x = 701` the correct value is
71/// `exp(-701) ≈ 5e-305` (essentially zero); a two-sided clamp would destroy
72/// far-tail exact derivatives.
73#[inline]
74pub fn exp_neg_stable(x: f64) -> f64 {
75    (-x).min(EXP_SATURATION_MAX_ARG).exp()
76}
77
78/// Inverse exp-link `1/σ = exp(-η)` with the one-sided representability
79/// guard from [`exp_neg_stable`]: exact for every η whose `exp(-η)` fits in
80/// `f64`, saturating near `f64::MAX` only past that boundary. Required by
81/// every solver path that forms products like `t · exp(-η_ls)` — without the
82/// guard, very negative η_ls produces `+inf`, which propagates as `NaN`
83/// through subsequent multiplications and breaks the monotonicity floor /
84/// penalty chain.
85#[inline]
86pub fn exp_sigma_inverse_from_eta_scalar(eta: f64) -> f64 {
87    exp_neg_stable(eta)
88}
89
90/// Standardized survival threshold q0 = -eta_t · exp(-eta_ls) with log-space
91/// overflow detection.
92///
93/// log|q0| = ln|eta_t| + (-eta_ls) is formed exactly (no argument cap), so
94/// the result equals the mathematical product for every representable
95/// magnitude; saturation to ±MAX happens only when |q0| genuinely exceeds
96/// `f64::MAX` — the representability boundary of the number format, not an
97/// arbitrary ceiling. When `exp(-eta_ls)` alone is unrepresentable but the
98/// product is finite (|eta_t| tiny), the magnitude is evaluated in the log
99/// domain instead of through the saturated factor.
100#[inline]
101pub fn survival_q0_from_eta(eta_t: f64, eta_ls: f64) -> f64 {
102    if eta_t == 0.0 {
103        return 0.0;
104    }
105    let log_abs = eta_t.abs().ln() - eta_ls;
106    if log_abs > EXP_SATURATION_MAX_ARG {
107        return if eta_t > 0.0 { -f64::MAX } else { f64::MAX };
108    }
109    if -eta_ls > EXP_SATURATION_MAX_ARG {
110        let mag = log_abs.exp();
111        return if eta_t > 0.0 { -mag } else { mag };
112    }
113    let q = -eta_t * exp_sigma_inverse_from_eta_scalar(eta_ls);
114    if q.is_finite() {
115        q
116    } else {
117        // Roundoff at the very edge of the representable band can push the
118        // direct product to ±inf even though log_abs cleared the check.
119        if eta_t > 0.0 { -f64::MAX } else { f64::MAX }
120    }
121}
122
123#[inline]
124pub fn exp_sigma_eta_for_sigma_scalar(sigma: f64) -> f64 {
125    assert!(
126        sigma.is_finite(),
127        "exp sigma inverse link requires finite sigma: sigma={sigma}"
128    );
129    assert!(
130        sigma > 0.0,
131        "exp sigma inverse link: sigma must be positive (got sigma={sigma})"
132    );
133    sigma.ln()
134}
135
136#[inline]
137pub fn exp_sigma_jet3_scalar(eta: f64) -> SigmaJet3 {
138    let jet = exp_sigma_jet4_scalar(eta);
139    SigmaJet3 {
140        sigma: jet.sigma,
141        d1: jet.d1,
142        d2: jet.d2,
143        d3: jet.d3,
144    }
145}
146
147#[inline]
148pub fn exp_sigma_derivs_up_to_third_scalar(eta: f64) -> (f64, f64, f64, f64) {
149    let jet = exp_sigma_jet3_scalar(eta);
150    (jet.sigma, jet.d1, jet.d2, jet.d3)
151}
152
153pub fn exp_sigma_derivs_up_to_third(
154    eta: ArrayView1<'_, f64>,
155) -> (Array1<f64>, Array1<f64>, Array1<f64>, Array1<f64>) {
156    let n = eta.len();
157    let mut sigma = Array1::<f64>::uninit(n);
158    let mut d1 = Array1::<f64>::uninit(n);
159    let mut d2 = Array1::<f64>::uninit(n);
160    let mut d3 = Array1::<f64>::uninit(n);
161    for i in 0..n {
162        let jet = exp_sigma_jet3_scalar(eta[i]);
163        sigma[i].write(jet.sigma);
164        d1[i].write(jet.d1);
165        d2[i].write(jet.d2);
166        d3[i].write(jet.d3);
167    }
168    // SAFETY: every slot in each length-`n` output is written exactly once by
169    // the loop over `0..n` before `assume_init`.
170    unsafe {
171        (
172            sigma.assume_init(),
173            d1.assume_init(),
174            d2.assume_init(),
175            d3.assume_init(),
176        )
177    }
178}
179
180#[inline]
181pub(crate) fn exp_sigma_jet4_scalar(eta: f64) -> SigmaJet4 {
182    let sigma = safe_exp(eta);
183    SigmaJet4 {
184        sigma,
185        d1: sigma,
186        d2: sigma,
187        d3: sigma,
188        d4: sigma,
189    }
190}
191
192#[inline]
193pub fn exp_sigma_derivs_up_to_fourth_scalar(eta: f64) -> (f64, f64, f64, f64, f64) {
194    let jet = exp_sigma_jet4_scalar(eta);
195    (jet.sigma, jet.d1, jet.d2, jet.d3, jet.d4)
196}
197
198pub fn exp_sigma_derivs_up_to_fourth(
199    eta: ArrayView1<'_, f64>,
200) -> (
201    Array1<f64>,
202    Array1<f64>,
203    Array1<f64>,
204    Array1<f64>,
205    Array1<f64>,
206) {
207    let n = eta.len();
208    let mut sigma = Array1::<f64>::uninit(n);
209    let mut d1 = Array1::<f64>::uninit(n);
210    let mut d2 = Array1::<f64>::uninit(n);
211    let mut d3 = Array1::<f64>::uninit(n);
212    let mut d4 = Array1::<f64>::uninit(n);
213    for i in 0..n {
214        let jet = exp_sigma_jet4_scalar(eta[i]);
215        sigma[i].write(jet.sigma);
216        d1[i].write(jet.d1);
217        d2[i].write(jet.d2);
218        d3[i].write(jet.d3);
219        d4[i].write(jet.d4);
220    }
221    // SAFETY: every slot in each length-`n` output is written exactly once by
222    // the loop over `0..n` before `assume_init`.
223    unsafe {
224        (
225            sigma.assume_init(),
226            d1.assume_init(),
227            d2.assume_init(),
228            d3.assume_init(),
229            d4.assume_init(),
230        )
231    }
232}
233
234/// Lower bound on σ in *response-scaled* units for the location-scale GAMLSS
235/// noise link σ = LOGB_SIGMA_FLOOR + exp(η). Mirrors mgcv's `gaulss(b=0.01)`
236/// default. The Gaussian location-scale log-likelihood
237///
238///   ℓ = −½ Σ (y−μ)²/σ² − Σ log σ
239///
240/// is unbounded *above* as σ → 0 with μ → y on any single observation
241/// (the −log σ term goes to +∞), so the *negative* log-likelihood is
242/// unbounded below and the unconstrained MLE does not exist. With
243/// σ ≥ b > 0 the −log σ term is bounded above by −log b, so the joint
244/// penalized objective stays finite for any finite data and the working
245/// weight 1/σ² is bounded by 1/b².
246///
247/// # Scale invariance
248///
249/// This 0.01 looks absolute but is *operationally* scale-relative: the single
250/// Gaussian location-scale model entry point
251/// (`fit_gaussian_location_scale_model` in `solver::fit_orchestration`) first computes
252/// `response_scale = sample_std(y).max(1e-6)` and fits on `y → y / response_scale`,
253/// then maps the fitted coefficients back to raw response units (the
254/// Location/Mean block scaled by `response_scale`, the log-σ block intercept
255/// shifted by `+ln(response_scale)`) via `rescale_gaussian_location_scale_to_raw`.
256/// Reconstructing σ from the returned coefficients is therefore
257///
258///   σ_response = response_scale · σ_internal
259///              = response_scale · (LOGB_SIGMA_FLOOR + exp(η_internal))
260///              = (response_scale · LOGB_SIGMA_FLOOR) + exp(η_internal + ln(response_scale)),
261///
262/// so the effective floor in response units is `0.01 · sample_std(y)` — exactly
263/// 1 % of the spread of `y`. This keeps κ = dlogσ/dη ≈ 1 across the realistic σ
264/// range, so the scale-block Fisher information matches gamlss's floorless 2a
265/// and the log-σ smooth traces the variance envelope instead of being
266/// over-smoothed. Under a rescaling `y → c·y` the prefit divides by `c` again,
267/// leaving the dimensionless internal floor unchanged. The single lingering
268/// breakage is the underflow guard `response_scale.max(1e-6)`: if the user feeds
269/// responses with `sample_std(y) < 1e-6` the floor stops tracking the data
270/// scale. That is a deliberate guard against a pathological constant-y input
271/// rather than a model assumption, and 1e-6 sits well below any sensible
272/// measurement-noise floor.
273///
274/// Equivariance requires the floor to scale **with** the response, not just the
275/// `exp(η)` term: the `+ln(response_scale)` intercept shift only multiplies the
276/// exponential by `response_scale`, leaving a residual `0.01·(1 − response_scale)`
277/// if the floor stayed at a raw `0.01`. The reconstruction therefore carries an
278/// explicit floor `response_scale · 0.01`
279/// ([`logb_sigma_from_eta_with_floor_scalar`]) so that
280/// `σ̂_{c·y}(x) = c · σ̂_y(x)` holds exactly (#884).
281pub const LOGB_SIGMA_FLOOR: f64 = 0.01;
282
283#[inline]
284pub fn logb_sigma_jet1_scalar(eta: f64) -> SigmaJet1 {
285    let s = safe_exp(eta);
286    SigmaJet1 {
287        sigma: LOGB_SIGMA_FLOOR + s,
288        d1: s,
289    }
290}
291
292#[inline]
293pub fn logb_sigma_from_eta_scalar(eta: f64) -> f64 {
294    LOGB_SIGMA_FLOOR + safe_exp(eta)
295}
296
297/// Reconstruct σ from η with an explicit, response-scale-relative floor.
298///
299/// The internal fit standardizes the response by `s = response_scale` and is
300/// solved with the dimensionless floor [`LOGB_SIGMA_FLOOR`]. Mapping back to
301/// raw response units the σ surface must scale uniformly,
302/// `σ_raw(η) = s · σ_internal(η)`, i.e. **both** the `exp(η)` term and the floor
303/// are multiplied by `s`. The `exp` term is carried by shifting the log-σ
304/// intercept by `+ln(s)`; the floor cannot ride an intercept shift (it sits
305/// outside the exponential), so the equivariant floor `floor = s · 0.01` is
306/// supplied here directly. With `floor = LOGB_SIGMA_FLOOR` this reduces to
307/// [`logb_sigma_from_eta_scalar`].
308#[inline]
309pub fn logb_sigma_from_eta_with_floor_scalar(floor: f64, eta: f64) -> f64 {
310    floor + safe_exp(eta)
311}
312
313#[inline]
314pub fn logb_sigma_eta_for_sigma_scalar(sigma: f64) -> f64 {
315    assert!(
316        sigma.is_finite(),
317        "logb sigma inverse link requires finite sigma: sigma={sigma}"
318    );
319    assert!(
320        sigma > LOGB_SIGMA_FLOOR,
321        "logb sigma inverse link: sigma must exceed LOGB_SIGMA_FLOOR (got sigma={sigma}, floor={LOGB_SIGMA_FLOOR})"
322    );
323    (sigma - LOGB_SIGMA_FLOOR).ln()
324}
325
326#[inline]
327pub fn logb_sigma_jet3_scalar(eta: f64) -> SigmaJet3 {
328    let jet = logb_sigma_jet4_scalar(eta);
329    SigmaJet3 {
330        sigma: jet.sigma,
331        d1: jet.d1,
332        d2: jet.d2,
333        d3: jet.d3,
334    }
335}
336
337#[inline]
338pub fn logb_sigma_derivs_up_to_third_scalar(eta: f64) -> (f64, f64, f64, f64) {
339    let jet = logb_sigma_jet3_scalar(eta);
340    (jet.sigma, jet.d1, jet.d2, jet.d3)
341}
342
343#[inline]
344pub(crate) fn logb_sigma_jet4_scalar(eta: f64) -> SigmaJet4 {
345    let s = safe_exp(eta);
346    SigmaJet4 {
347        sigma: LOGB_SIGMA_FLOOR + s,
348        d1: s,
349        d2: s,
350        d3: s,
351        d4: s,
352    }
353}
354
355#[inline]
356pub fn logb_sigma_derivs_up_to_fourth_scalar(eta: f64) -> (f64, f64, f64, f64, f64) {
357    let jet = logb_sigma_jet4_scalar(eta);
358    (jet.sigma, jet.d1, jet.d2, jet.d3, jet.d4)
359}
360
361pub fn logb_sigma_derivs_up_to_fourth(
362    eta: ArrayView1<'_, f64>,
363) -> (
364    Array1<f64>,
365    Array1<f64>,
366    Array1<f64>,
367    Array1<f64>,
368    Array1<f64>,
369) {
370    let n = eta.len();
371    let mut sigma = Array1::<f64>::uninit(n);
372    let mut d1 = Array1::<f64>::uninit(n);
373    let mut d2 = Array1::<f64>::uninit(n);
374    let mut d3 = Array1::<f64>::uninit(n);
375    let mut d4 = Array1::<f64>::uninit(n);
376    for i in 0..n {
377        let jet = logb_sigma_jet4_scalar(eta[i]);
378        sigma[i].write(jet.sigma);
379        d1[i].write(jet.d1);
380        d2[i].write(jet.d2);
381        d3[i].write(jet.d3);
382        d4[i].write(jet.d4);
383    }
384    // SAFETY: every slot in each length-`n` output is written exactly once by
385    // the loop over `0..n` before `assume_init`.
386    unsafe {
387        (
388            sigma.assume_init(),
389            d1.assume_init(),
390            d2.assume_init(),
391            d3.assume_init(),
392            d4.assume_init(),
393        )
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use std::fs;
401    use std::path::Path;
402
403    fn collect_rs_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
404        let Ok(entries) = fs::read_dir(dir) else {
405            return;
406        };
407        for entry in entries.flatten() {
408            let path = entry.path();
409            if path.is_dir() {
410                collect_rs_files(&path, out);
411                continue;
412            }
413            if path.extension().and_then(|e| e.to_str()) == Some("rs") {
414                out.push(path);
415            }
416        }
417    }
418
419    fn stripwhitespace(s: &str) -> String {
420        s.chars().filter(|c| !c.is_whitespace()).collect()
421    }
422
423    #[test]
424    fn forbid_bounded_sigma_link_pattern_in_source() {
425        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
426        let mut files = Vec::new();
427        collect_rs_files(&root, &mut files);
428
429        // This module (`sigma_link.rs`) is the canonical home of the σ-link
430        // implementation and the guard itself: the forbidden strings appear
431        // here verbatim in `bad_patterns`, so scanning our own source would
432        // always self-trip. Skip exactly this file — every *other* file under
433        // `src/` is still checked.
434        let self_file = root.join("sigma_link.rs");
435
436        let bad_patterns = [
437            "bounded_sigma",
438            "model.sigma_min",
439            "model.sigma_max",
440            "payload.sigma_min",
441            "payload.sigma_max",
442            "survival_sigma_min",
443            "survival_sigma_max",
444            "fnsafe_sigma_from_eta(",
445            "fnsigma_and_deriv_from_eta(",
446            "fnsigma_from_eta_scalar(",
447        ];
448
449        for file in files {
450            if file == self_file {
451                continue;
452            }
453            let Ok(content) = fs::read_to_string(&file) else {
454                continue;
455            };
456            let compact = stripwhitespace(&content);
457            for pat in bad_patterns {
458                assert!(
459                    !compact.contains(pat),
460                    "forbidden sigma link pattern '{pat}' found in {}",
461                    file.display()
462                );
463            }
464        }
465    }
466
467    /// FD check shared by the `exp_sigma` and `logb_sigma` derivative
468    /// tests. Captures the closed-form analytic derivatives at `eta`
469    /// and compares them against second/third FD stencils built from
470    /// the link's scalar σ(η) over a small offset, with relative
471    /// tolerances tuned for the d²/d³ stencil amplification (the d³
472    /// stencil amplifies roundoff by ~1/h³).
473    fn assert_sigma_derivs_match_fd(
474        sigma_at: impl Fn(f64) -> f64,
475        derivs_at: impl Fn(f64) -> (f64, f64, f64, f64),
476    ) {
477        let h = 1e-5;
478        let h3 = 2e-3;
479        let points = [-6.0, -3.5, -1.2, 0.0, 0.8, 2.1, 6.0];
480
481        for &eta in &points {
482            let (s, d1, d2, d3) = derivs_at(eta);
483            let s_plus = sigma_at(eta + h);
484            let s_minus = sigma_at(eta - h);
485
486            let d1fd = (s_plus - s_minus) / (2.0 * h);
487            let d2fd = (s_plus - 2.0 * s + s_minus) / (h * h);
488            let d2_at = |x: f64| {
489                let xp = sigma_at(x + h3);
490                let xc = sigma_at(x);
491                let xm = sigma_at(x - h3);
492                (xp - 2.0 * xc + xm) / (h3 * h3)
493            };
494            let d3fd = (d2_at(eta + h3) - d2_at(eta - h3)) / (2.0 * h3);
495
496            let d1_scale = d1.abs().max(d1fd.abs()).max(1.0);
497            let d2_scale = d2.abs().max(d2fd.abs()).max(1.0);
498            let d3_scale = d3.abs().max(d3fd.abs()).max(1.0);
499
500            assert!((d1 - d1fd).abs() < 1e-8 * d1_scale);
501            assert!((d2 - d2fd).abs() < 1e-5 * d2_scale);
502            assert!((d3 - d3fd).abs() < 5e-4 * d3_scale);
503        }
504    }
505
506    #[test]
507    fn exp_sigma_derivatives_match_finite_difference() {
508        assert_sigma_derivs_match_fd(
509            exp_sigma_from_eta_scalar,
510            exp_sigma_derivs_up_to_third_scalar,
511        );
512    }
513
514    #[test]
515    fn exp_sigma_fourth_derivative_matches_finite_difference() {
516        let h = 2e-3;
517        let points = [-6.0, -3.0, -1.1, 0.0, 0.6, 1.9, 5.5];
518
519        let d3_at = |x: f64| exp_sigma_derivs_up_to_third_scalar(x).3;
520        for &eta in &points {
521            let (_, d1_4, d2_4, d3_4, d4_4) = exp_sigma_derivs_up_to_fourth_scalar(eta);
522            let (_, d1_3, d2_3, d3_3) = exp_sigma_derivs_up_to_third_scalar(eta);
523            assert!((d1_4 - d1_3).abs() < 1e-12);
524            assert!((d2_4 - d2_3).abs() < 1e-12);
525            assert!((d3_4 - d3_3).abs() < 1e-12);
526
527            let d4fd = (d3_at(eta + h) - d3_at(eta - h)) / (2.0 * h);
528            let d4_scale = d4_4.abs().max(d4fd.abs()).max(1.0);
529            assert!((d4_4 - d4fd).abs() < 5e-4 * d4_scale);
530        }
531    }
532
533    #[test]
534    fn exp_sigmavectorized_up_to_fourth_matches_scalar() {
535        let eta = Array1::from_vec(vec![-701.0, -4.2, -1.4, -0.2, 0.4, 1.9, 3.1, 701.0]);
536        let (s, d1, d2, d3, d4) = exp_sigma_derivs_up_to_fourth(eta.view());
537        for i in 0..eta.len() {
538            let (ss, d1s, d2s, d3s, d4s) = exp_sigma_derivs_up_to_fourth_scalar(eta[i]);
539            assert!((s[i] - ss).abs() < 1e-12);
540            assert!((d1[i] - d1s).abs() < 1e-12);
541            assert!((d2[i] - d2s).abs() < 1e-12);
542            assert!((d3[i] - d3s).abs() < 1e-12);
543            assert!((d4[i] - d4s).abs() < 1e-12);
544        }
545    }
546
547    #[test]
548    fn exp_sigma_inverse_accepts_positive_sigma() {
549        let eta = exp_sigma_eta_for_sigma_scalar(2.5);
550        assert!(eta.is_finite());
551        assert!((eta - 2.5_f64.ln()).abs() < 1e-12);
552    }
553
554    #[test]
555    #[should_panic(expected = "sigma must be positive")]
556    fn exp_sigma_inverse_rejects_non_positive_sigma() {
557        exp_sigma_eta_for_sigma_scalar(0.0);
558    }
559
560    #[test]
561    fn safe_exp_matches_native_exp_semantics() {
562        assert!(safe_exp(0.0).is_finite());
563        assert!(safe_exp(700.0).is_finite());
564        assert!(safe_exp(-700.0).is_finite());
565        assert!(safe_exp(1000.0).is_infinite());
566        assert_eq!(safe_exp(-1000.0), 0.0);
567        assert!(safe_exp(f64::MAX).is_infinite());
568        assert_eq!(safe_exp(f64::MIN), 0.0);
569        assert!((safe_exp(1.0) - 1.0_f64.exp()).abs() < 1e-15);
570        assert!((safe_exp(-5.0) - (-5.0_f64).exp()).abs() < 1e-15);
571    }
572
573    #[test]
574    fn exp_sigma_derivatives_match_exact_exp_in_far_tails() {
575        for &eta in &[709.0, -745.0] {
576            let (sigma, d1, d2, d3, d4) = exp_sigma_derivs_up_to_fourth_scalar(eta);
577            assert_eq!(sigma, eta.exp());
578            assert_eq!(d1, sigma);
579            assert_eq!(d2, sigma);
580            assert_eq!(d3, sigma);
581            assert_eq!(d4, sigma);
582        }
583    }
584
585    #[test]
586    fn logb_sigma_floor_bounds_below_for_arbitrarily_negative_eta() {
587        for &eta in &[-1000.0, -100.0, -50.0, -10.0] {
588            let sigma = logb_sigma_from_eta_scalar(eta);
589            assert!(sigma >= LOGB_SIGMA_FLOOR);
590            assert!(sigma.is_finite());
591            let inv_s2 = (sigma * sigma).recip();
592            assert!(inv_s2 <= LOGB_SIGMA_FLOOR.powi(-2) + 1e-12);
593        }
594    }
595
596    #[test]
597    fn logb_sigma_recovers_exp_link_in_upper_regime() {
598        for &eta in &[3.0, 5.0, 10.0] {
599            let logb = logb_sigma_from_eta_scalar(eta);
600            let pure_exp = exp_sigma_from_eta_scalar(eta);
601            let rel_err = (logb - pure_exp).abs() / pure_exp;
602            assert!(rel_err < 1e-2);
603        }
604    }
605
606    #[test]
607    fn logb_sigma_jet_d1_through_d4_match_pure_exp_eta() {
608        for &eta in &[-3.0_f64, 0.0, 2.0] {
609            let s = eta.exp();
610            let jet1 = logb_sigma_jet1_scalar(eta);
611            let jet3 = logb_sigma_jet3_scalar(eta);
612            let jet4 = logb_sigma_jet4_scalar(eta);
613            assert!((jet1.sigma - (LOGB_SIGMA_FLOOR + s)).abs() < 1e-12);
614            assert!((jet1.d1 - s).abs() < 1e-12);
615            assert!((jet3.sigma - (LOGB_SIGMA_FLOOR + s)).abs() < 1e-12);
616            assert!((jet3.d1 - s).abs() < 1e-12);
617            assert!((jet3.d2 - s).abs() < 1e-12);
618            assert!((jet3.d3 - s).abs() < 1e-12);
619            assert!((jet4.d4 - s).abs() < 1e-12);
620        }
621    }
622
623    #[test]
624    fn logb_sigma_derivatives_match_finite_difference() {
625        assert_sigma_derivs_match_fd(
626            logb_sigma_from_eta_scalar,
627            logb_sigma_derivs_up_to_third_scalar,
628        );
629    }
630
631    #[test]
632    fn logb_sigma_inverse_round_trip() {
633        for &sigma in &[
634            LOGB_SIGMA_FLOOR + 1e-3,
635            LOGB_SIGMA_FLOOR + 0.5,
636            1.0,
637            10.0,
638            1e6,
639        ] {
640            let eta = logb_sigma_eta_for_sigma_scalar(sigma);
641            let recovered = logb_sigma_from_eta_scalar(eta);
642            let scale = sigma.abs().max(1.0);
643            assert!((recovered - sigma).abs() < 1e-10 * scale);
644        }
645    }
646
647    #[test]
648    #[should_panic(expected = "sigma must exceed LOGB_SIGMA_FLOOR")]
649    fn logb_sigma_inverse_rejects_sigma_at_floor() {
650        logb_sigma_eta_for_sigma_scalar(LOGB_SIGMA_FLOOR);
651    }
652
653    #[test]
654    fn logb_sigma_vectorized_matches_scalar() {
655        let eta = Array1::from_vec(vec![-701.0, -4.2, -1.4, -0.2, 0.4, 1.9, 3.1, 701.0]);
656        let (s, d1, d2, d3, d4) = logb_sigma_derivs_up_to_fourth(eta.view());
657        for i in 0..eta.len() {
658            let (ss, d1s, d2s, d3s, d4s) = logb_sigma_derivs_up_to_fourth_scalar(eta[i]);
659            assert!((s[i] - ss).abs() < 1e-12);
660            assert!((d1[i] - d1s).abs() < 1e-12);
661            assert!((d2[i] - d2s).abs() < 1e-12);
662            assert!((d3[i] - d3s).abs() < 1e-12);
663            assert!((d4[i] - d4s).abs() < 1e-12);
664        }
665    }
666}