Skip to main content

dashu_float/math/
hyper.rs

1//! Hyperbolic functions, built from the cancellation-free `exp_m1` / `ln_1p` primitives:
2//!
3//! - `sinh(x) = (exp_m1(x) - exp_m1(-x)) / 2`
4//! - `cosh(x) = (exp_m1(x) + exp_m1(-x)) / 2 + 1`
5//! - `tanh(x) = exp_m1(2x) / (exp_m1(2x) + 2)`
6//! - `asinh(x) = sign(x) · ln_1p(|x| + x²/(sqrt(x²+1)+1))`
7//! - `acosh(x) = ln_1p((x-1) + sqrt((x-1)(x+1)))`  (x ≥ 1)
8//! - `atanh(x) = ln_1p(2x/(1-x)) / 2`  (|x| < 1)
9//!
10//! The `exp_m1` / `ln_1p` forms avoid the catastrophic cancellation that the naive
11//! `(exp(x)-exp(-x))/2` and `ln(1+…)` formulas suffer for small arguments. Special
12//! values follow IEEE 754: infinities are values (not errors) for the forward functions
13//! and `asinh`; `acosh(x<1)` and `atanh(|x|>1)` are domain errors.
14
15use crate::{
16    ball::Ball,
17    error::{assert_limited_precision, FpError},
18    fbig::FBig,
19    math::{
20        cache::{reborrow_cache, ConstCache},
21        FpResult,
22    },
23    repr::{Context, Repr, Word},
24    round::{mode, ErrorBounds},
25};
26use dashu_base::{Abs, AbsOrd, Approximation::Exact, BitTest, Sign};
27use dashu_int::IBig;
28
29impl<R: ErrorBounds> Context<R> {
30    /// Hyperbolic sine.
31    pub fn sinh<const B: Word>(
32        &self,
33        x: &Repr<B>,
34        mut cache: Option<&mut ConstCache>,
35    ) -> FpResult<FBig<R, B>> {
36        if x.is_infinite() {
37            return Ok(Exact(FBig::new(Repr::infinity_with_sign(x.sign()), *self)));
38        }
39        assert_limited_precision(self.precision);
40        if x.significand.is_zero() {
41            // sinh(±0) = ±0
42            return Ok(Exact(FBig::new(signed_zero_repr(x), *self)));
43        }
44        // sinh(x) = (exp_m1(x) - exp_m1(-x)) / 2  (cancellation-free). Both `exp_m1` come from the
45        // Ball-based `exp_compute`; the subtraction/division roundings and the `exp_m1` errors are
46        // tracked mechanically by the Ball propagation. For huge |x|, `exp_m1` overflows inside the
47        // closure and propagates; sinh(±huge) = ±inf, so the sign follows `x` (the propagated error
48        // carries an intermediate sign, remapped below).
49        let initial_guard = self.base_guard_digits::<B>() + 10;
50        self.ziv(initial_guard, |guard| {
51            let work = Context::<mode::HalfEven>::new(self.precision + guard);
52            let n = 1usize << (work.precision.bit_len() / 2);
53            let x_f = FBig::<mode::HalfEven, B>::new(work.repr_round_ref(x).value(), work);
54            let ep = work.exp_compute::<B>(
55                &x_f.repr,
56                work.precision,
57                true,
58                n,
59                reborrow_cache(&mut cache),
60            )?;
61            let em = work.exp_compute::<B>(
62                &(-x_f.clone()).repr,
63                work.precision,
64                true,
65                n,
66                reborrow_cache(&mut cache),
67            )?;
68            Ok(ep.sub(&em).div_int(2).to_value_radius::<R>())
69        })
70        .map_err(|_| FpError::Overflow(x.sign()))
71    }
72
73    /// Hyperbolic cosine.
74    pub fn cosh<const B: Word>(
75        &self,
76        x: &Repr<B>,
77        mut cache: Option<&mut ConstCache>,
78    ) -> FpResult<FBig<R, B>> {
79        if x.is_infinite() {
80            // cosh(±inf) = +inf
81            return Ok(Exact(FBig::new(Repr::infinity(), *self)));
82        }
83        assert_limited_precision(self.precision);
84        if x.significand.is_zero() {
85            // cosh(±0) = 1
86            return Ok(Exact(FBig::new(Repr::one(), *self)));
87        }
88
89        // cosh(x) = (exp_m1(x) + exp_m1(-x)) / 2 + 1 (no cancellation: same-sign sum). Both
90        // `exp_m1` come from the Ball-based `exp_compute`; the sum/divide/+1 roundings are tracked
91        // mechanically. For huge |x|, `exp_m1` overflows inside the closure and propagates;
92        // cosh(±huge) = +inf (always positive).
93        let initial_guard = self.base_guard_digits::<B>() + 10;
94        self.ziv(initial_guard, |guard| {
95            let work = Context::<mode::HalfEven>::new(self.precision + guard);
96            let n = 1usize << (work.precision.bit_len() / 2);
97            let x_f = FBig::<mode::HalfEven, B>::new(work.repr_round_ref(x).value(), work);
98            let ep = work.exp_compute::<B>(
99                &x_f.repr,
100                work.precision,
101                true,
102                n,
103                reborrow_cache(&mut cache),
104            )?;
105            let em = work.exp_compute::<B>(
106                &(-x_f.clone()).repr,
107                work.precision,
108                true,
109                n,
110                reborrow_cache(&mut cache),
111            )?;
112            let one = Ball::exact_int(work.precision, IBig::ONE);
113            Ok(ep.add(&em).div_int(2).add(&one).to_value_radius::<R>())
114        })
115        .map_err(|_| FpError::Overflow(Sign::Positive))
116    }
117
118    /// Simultaneously compute `sinh(x)` and `cosh(x)` (context layer). Returns
119    /// `(sinh_result, cosh_result)` where each is a [`FpResult`].
120    ///
121    /// This is more efficient than calling [`sinh`](Context::sinh) and [`cosh`](Context::cosh)
122    /// separately, since the two share the `exp_m1(±x)` sub-computations.
123    pub fn sinh_cosh<const B: Word>(
124        &self,
125        x: &Repr<B>,
126        mut cache: Option<&mut ConstCache>,
127    ) -> (FpResult<FBig<R, B>>, FpResult<FBig<R, B>>) {
128        if x.is_infinite() {
129            return (
130                Ok(Exact(FBig::new(Repr::infinity_with_sign(x.sign()), *self))),
131                Ok(Exact(FBig::new(Repr::infinity(), *self))),
132            );
133        }
134        assert_limited_precision(self.precision);
135        if x.significand.is_zero() {
136            return (
137                Ok(Exact(FBig::new(signed_zero_repr(x), *self))),
138                Ok(Exact(FBig::new(Repr::one(), *self))),
139            );
140        }
141
142        // sinh = (ep - em)/2; cosh = (ep + em)/2 + 1, sharing the two `exp_m1` calls. Certified as a
143        // pair via `ziv_pair` (retry while either endpoint straddles a boundary). For huge |x|,
144        // `exp_m1` overflows inside the closure and propagates to both slots; sinh(±huge) = ±inf,
145        // cosh(±huge) = +inf, so each slot's overflow sign is remapped below.
146        let initial_guard = self.base_guard_digits::<B>() + 10;
147        let (sinh_r, cosh_r) = self.ziv_pair(initial_guard, |guard| {
148            let work = Context::<mode::HalfEven>::new(self.precision + guard);
149            let n = 1usize << (work.precision.bit_len() / 2);
150            let x_f = FBig::<mode::HalfEven, B>::new(work.repr_round_ref(x).value(), work);
151            let ep = work.exp_compute::<B>(
152                &x_f.repr,
153                work.precision,
154                true,
155                n,
156                reborrow_cache(&mut cache),
157            )?;
158            let em = work.exp_compute::<B>(
159                &(-x_f.clone()).repr,
160                work.precision,
161                true,
162                n,
163                reborrow_cache(&mut cache),
164            )?;
165            let one = Ball::exact_int(work.precision, IBig::ONE);
166            let sinh_ball = ep.sub(&em).div_int(2);
167            let cosh_ball = ep.add(&em).div_int(2).add(&one);
168            Ok((sinh_ball.to_value_radius::<R>(), cosh_ball.to_value_radius::<R>()))
169        });
170        (
171            sinh_r.map_err(|_| FpError::Overflow(x.sign())),
172            cosh_r.map_err(|_| FpError::Overflow(Sign::Positive)),
173        )
174    }
175
176    /// Hyperbolic tangent.
177    pub fn tanh<const B: Word>(
178        &self,
179        x: &Repr<B>,
180        mut cache: Option<&mut ConstCache>,
181    ) -> FpResult<FBig<R, B>> {
182        if x.is_infinite() {
183            // tanh(±inf) = ±1
184            let one = FBig::new(Repr::one(), *self);
185            return Ok(Exact(if x.sign() == Sign::Negative {
186                -one
187            } else {
188                one
189            }));
190        }
191        assert_limited_precision(self.precision);
192        if x.significand.is_zero() {
193            // tanh(±0) = ±0
194            return Ok(Exact(FBig::new(signed_zero_repr(x), *self)));
195        }
196
197        // tanh(x) = exp_m1(2x) / (exp_m1(2x) + 2). `exp_m1(2x)` comes from the Ball-based
198        // `exp_compute`; the division's rounding is tracked mechanically. For large positive x it
199        // overflows → tanh = +1 (returned inline as an exact value); for large negative x,
200        // exp_m1(2x) → -1 (finite), so tanh → -1 naturally.
201        let initial_guard = self.base_guard_digits::<B>() + 10;
202        self.ziv(initial_guard, |guard| {
203            let work = Context::<mode::HalfEven>::new(self.precision + guard);
204            let n = 1usize << (work.precision.bit_len() / 2);
205            let x_f = FBig::<mode::HalfEven, B>::new(work.repr_round_ref(x).value(), work);
206            let two_x = x_f * 2i32;
207            match work.exp_compute::<B>(
208                &two_x.repr,
209                work.precision,
210                true,
211                n,
212                reborrow_cache(&mut cache),
213            ) {
214                Err(FpError::Overflow(_)) => Ok((FBig::<R, B>::ONE, FBig::<R, B>::ZERO)), // exact +1
215                Ok(e) => {
216                    let two = Ball::exact_int(work.precision, IBig::from(2));
217                    Ok(e.div(&e.add(&two)).to_value_radius::<R>())
218                }
219                Err(other) => unreachable!("exp_m1 on finite input: {other:?}"),
220            }
221        })
222    }
223
224    /// Inverse hyperbolic sine.
225    pub fn asinh<const B: Word>(
226        &self,
227        x: &Repr<B>,
228        mut cache: Option<&mut ConstCache>,
229    ) -> FpResult<FBig<R, B>> {
230        if x.is_infinite() {
231            return Ok(Exact(FBig::new(Repr::infinity_with_sign(x.sign()), *self)));
232        }
233        assert_limited_precision(self.precision);
234        if x.significand.is_zero() {
235            // asinh(±0) = ±0
236            return Ok(Exact(FBig::new(signed_zero_repr(x), *self)));
237        }
238
239        // asinh(x) = sign(x) · ln_1p(|x| + x²/(sqrt(x²+1)+1)) — the x²/(sqrt+1) form avoids the
240        // `sqrt(x²+1) − 1` cancellation near 0. The composition is tracked as a [`Ball`]: the sqr,
241        // sqrt, division and the `ln_1p` input error all propagate mechanically. The `|x|` so large
242        // that `x²` overflows arm falls back to the asymptotic `sign·ln(2|x|)`.
243        let initial_guard = self.base_guard_digits::<B>() + 10;
244        self.ziv(initial_guard, |guard| {
245            let work = Context::<mode::HalfEven>::new(self.precision + guard);
246            let x_f = FBig::<mode::HalfEven, B>::new(work.repr_round_ref(x).value(), work);
247            let sign = x_f.sign();
248            let abs_x = x_f.abs();
249            let res = match work.sqr(&abs_x.repr) {
250                Ok(x_sq) => {
251                    let x_sq_ball = Ball::from_rounded(x_sq); // correctly-rounded sqr
252                    let one = Ball::exact_int(work.precision, IBig::ONE);
253                    let sqrt_plus_one = x_sq_ball.add(&one).sqrt().add(&one);
254                    let abs_x_ball = Ball::with_error(abs_x, IBig::ONE);
255                    let arg = abs_x_ball.add(&x_sq_ball.div(&sqrt_plus_one));
256                    work.ln_1p_ball::<B>(&arg, reborrow_cache(&mut cache))
257                }
258                // |x| so large that x² overflows: asinh(x) ≈ sign·ln(2|x|).
259                Err(FpError::Overflow(_)) => {
260                    let two_abs = abs_x * 2i32;
261                    work.ln_compute::<B>(
262                        &two_abs.repr,
263                        work.precision,
264                        false,
265                        reborrow_cache(&mut cache),
266                    )
267                }
268                Err(other) => unreachable!("sqr: {other:?}"),
269            };
270            let result = if sign == Sign::Negative {
271                res.neg()
272            } else {
273                res
274            };
275            Ok(result.to_value_radius::<R>())
276        })
277    }
278
279    /// Inverse hyperbolic cosine. Domain: `x ≥ 1`.
280    pub fn acosh<const B: Word>(
281        &self,
282        x: &Repr<B>,
283        mut cache: Option<&mut ConstCache>,
284    ) -> FpResult<FBig<R, B>> {
285        if x.is_infinite() {
286            if x.sign() == Sign::Negative {
287                return Err(FpError::OutOfDomain);
288            }
289            return Ok(Exact(FBig::new(Repr::infinity(), *self)));
290        }
291        assert_limited_precision(self.precision);
292        // domain x ≥ 1 (acosh(1) = 0 is handled below; x < 1 is an error)
293        if x.sign() == Sign::Negative
294            || FBig::<R, B>::new(x.clone(), *self)
295                .abs_cmp(&FBig::ONE)
296                .is_lt()
297        {
298            return Err(FpError::OutOfDomain);
299        }
300        if x.is_one() {
301            return Ok(Exact(FBig::new(Repr::zero(), *self)));
302        }
303
304        // acosh(x) = ln_1p((x-1) + sqrt((x-1)(x+1))) — the (x-1)(x+1) form avoids the `x²−1`
305        // cancellation near x = 1. The composition is tracked as a [`Ball`]: the product, sqrt,
306        // addition and the `ln_1p` input error propagate mechanically. The `(x-1)(x+1)` overflow arm
307        // falls back to the asymptotic `ln(2x)`.
308        let initial_guard = self.base_guard_digits::<B>() + 10;
309        self.ziv(initial_guard, |guard| {
310            let work = Context::<mode::HalfEven>::new(self.precision + guard);
311            let x_f = FBig::<mode::HalfEven, B>::new(work.repr_round_ref(x).value(), work);
312            let xm1 = &x_f - FBig::<mode::HalfEven, B>::ONE;
313            let xp1 = &x_f + FBig::<mode::HalfEven, B>::ONE;
314            let res = match work.mul(&xm1.repr, &xp1.repr) {
315                Ok(prod) => {
316                    let prod_ball = Ball::from_rounded(prod); // correctly-rounded product
317                    let xm1_ball = Ball::with_error(xm1, IBig::ONE);
318                    let arg = xm1_ball.add(&prod_ball.sqrt());
319                    work.ln_1p_ball::<B>(&arg, reborrow_cache(&mut cache))
320                }
321                // (x-1)(x+1) overflowed: acosh(x) ≈ ln(2x).
322                Err(FpError::Overflow(_)) => {
323                    let two_x = x_f.clone() * 2i32;
324                    work.ln_compute::<B>(
325                        &two_x.repr,
326                        work.precision,
327                        false,
328                        reborrow_cache(&mut cache),
329                    )
330                }
331                Err(other) => unreachable!("mul: {other:?}"),
332            };
333            Ok(res.to_value_radius::<R>())
334        })
335    }
336
337    /// Inverse hyperbolic tangent. Domain: `-1 < x < 1` (`x = ±1` → ±∞, `|x| > 1` is an error).
338    pub fn atanh<const B: Word>(
339        &self,
340        x: &Repr<B>,
341        mut cache: Option<&mut ConstCache>,
342    ) -> FpResult<FBig<R, B>> {
343        if x.is_infinite() {
344            return Err(FpError::OutOfDomain);
345        }
346        assert_limited_precision(self.precision);
347        if x.significand.is_zero() {
348            // atanh(±0) = ±0
349            return Ok(Exact(FBig::new(signed_zero_repr(x), *self)));
350        }
351        // domain |x| < 1: |x| = 1 → ±∞ (value), |x| > 1 → error
352        match FBig::<R, B>::new(x.clone(), *self).abs_cmp(&FBig::ONE) {
353            core::cmp::Ordering::Greater => return Err(FpError::OutOfDomain),
354            core::cmp::Ordering::Equal => {
355                return Ok(Exact(FBig::new(Repr::infinity_with_sign(x.sign()), *self)));
356            }
357            _ => {}
358        }
359
360        // atanh(x) = ln_1p(2x/(1-x)) / 2. The ratio and the `ln_1p` input error are tracked as a
361        // [`Ball`]; near |x| = 1 the `2x/(1-x)` division amplifies, but the Ball tracks it (Ziv
362        // retries there).
363        let initial_guard = self.base_guard_digits::<B>() + 10;
364        self.ziv(initial_guard, |guard| {
365            let work = Context::<mode::HalfEven>::new(self.precision + guard);
366            let x_ball = Ball::from_rounded(work.repr_round_ref(x).map(|r| FBig::new(r, work)));
367            let one = Ball::exact_int(work.precision, IBig::ONE);
368            let ratio = x_ball.scale_int(&IBig::from(2)).div(&one.sub(&x_ball));
369            let res = work.ln_1p_ball::<B>(&ratio, reborrow_cache(&mut cache));
370            Ok(res.div_int(2).to_value_radius::<R>())
371        })
372    }
373}
374
375impl<R: ErrorBounds, const B: Word> FBig<R, B> {
376    /// Calculate the hyperbolic sine of the floating point number.
377    ///
378    /// # Examples
379    ///
380    /// ```
381    /// # use core::str::FromStr;
382    /// # use dashu_base::ParseError;
383    /// # use dashu_float::DBig;
384    /// let a = DBig::from_str("0.5000000")?;
385    /// assert_eq!(a.sinh(), DBig::from_str("0.52109531")?);
386    /// # Ok::<(), ParseError>(())
387    /// ```
388    #[inline]
389    pub fn sinh(&self) -> Self {
390        self.context.unwrap_fp(self.context.sinh(&self.repr, None))
391    }
392
393    /// Calculate the hyperbolic cosine of the floating point number.
394    ///
395    /// # Examples
396    ///
397    /// ```
398    /// # use core::str::FromStr;
399    /// # use dashu_base::ParseError;
400    /// # use dashu_float::DBig;
401    /// let a = DBig::from_str("0.5000000")?;
402    /// assert_eq!(a.cosh(), DBig::from_str("1.127626")?);
403    /// # Ok::<(), ParseError>(())
404    /// ```
405    #[inline]
406    pub fn cosh(&self) -> Self {
407        self.context.unwrap_fp(self.context.cosh(&self.repr, None))
408    }
409
410    /// Simultaneously calculate the hyperbolic sine and cosine of the number.
411    ///
412    /// This is more efficient than calling [`sinh`](FBig::sinh) and [`cosh`](FBig::cosh)
413    /// separately, since the two share the `exp_m1(±x)` sub-computations.
414    ///
415    /// # Examples
416    ///
417    /// ```
418    /// # use core::str::FromStr;
419    /// # use dashu_base::ParseError;
420    /// # use dashu_float::DBig;
421    /// let a = DBig::from_str("0.5000000")?;
422    /// let (s, c) = a.sinh_cosh();
423    /// assert_eq!(s, DBig::from_str("0.52109531")?);
424    /// assert_eq!(c, DBig::from_str("1.127626")?);
425    /// # Ok::<(), ParseError>(())
426    /// ```
427    #[inline]
428    pub fn sinh_cosh(&self) -> (Self, Self) {
429        let (s, c) = self.context.sinh_cosh(&self.repr, None);
430        (self.context.unwrap_fp(s), self.context.unwrap_fp(c))
431    }
432
433    /// Calculate the hyperbolic tangent of the floating point number.
434    ///
435    /// # Examples
436    ///
437    /// ```
438    /// # use core::str::FromStr;
439    /// # use dashu_base::ParseError;
440    /// # use dashu_float::DBig;
441    /// let a = DBig::from_str("0.5000000")?;
442    /// assert_eq!(a.tanh(), DBig::from_str("0.46211716")?);
443    /// # Ok::<(), ParseError>(())
444    /// ```
445    #[inline]
446    pub fn tanh(&self) -> Self {
447        self.context.unwrap_fp(self.context.tanh(&self.repr, None))
448    }
449
450    /// Calculate the inverse hyperbolic sine of the floating point number.
451    ///
452    /// # Examples
453    ///
454    /// ```
455    /// # use core::str::FromStr;
456    /// # use dashu_base::ParseError;
457    /// # use dashu_float::DBig;
458    /// let a = DBig::from_str("0.5000000")?;
459    /// assert_eq!(a.asinh(), DBig::from_str("0.48121183")?);
460    /// # Ok::<(), ParseError>(())
461    /// ```
462    #[inline]
463    pub fn asinh(&self) -> Self {
464        self.context.unwrap_fp(self.context.asinh(&self.repr, None))
465    }
466
467    /// Calculate the inverse hyperbolic cosine of the floating point number.
468    ///
469    /// # Panics
470    ///
471    /// Panics if the number is less than 1 (out of domain).
472    ///
473    /// # Examples
474    ///
475    /// ```
476    /// # use core::str::FromStr;
477    /// # use dashu_base::ParseError;
478    /// # use dashu_float::DBig;
479    /// let a = DBig::from_str("2.000000")?;
480    /// assert_eq!(a.acosh(), DBig::from_str("1.316958")?);
481    /// # Ok::<(), ParseError>(())
482    /// ```
483    #[inline]
484    pub fn acosh(&self) -> Self {
485        self.context.unwrap_fp(self.context.acosh(&self.repr, None))
486    }
487
488    /// Calculate the inverse hyperbolic tangent of the floating point number.
489    ///
490    /// # Panics
491    ///
492    /// Panics if the absolute value is greater than or equal to 1 (out of domain;
493    /// `|x| = 1` is infinite and `|x| > 1` is not real).
494    ///
495    /// # Examples
496    ///
497    /// ```
498    /// # use core::str::FromStr;
499    /// # use dashu_base::ParseError;
500    /// # use dashu_float::DBig;
501    /// let a = DBig::from_str("0.5000000")?;
502    /// assert_eq!(a.atanh(), DBig::from_str("0.54930614")?);
503    /// # Ok::<(), ParseError>(())
504    /// ```
505    #[inline]
506    pub fn atanh(&self) -> Self {
507        self.context.unwrap_fp(self.context.atanh(&self.repr, None))
508    }
509}
510
511/// `±0` `Repr` carrying the sign of `x` (used by the odd hyperbolics at zero input).
512fn signed_zero_repr<const B: Word>(x: &Repr<B>) -> Repr<B> {
513    if x.is_neg_zero() {
514        Repr::neg_zero()
515    } else {
516        Repr::zero()
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::round::mode;
524    use dashu_int::IBig;
525
526    // `sinh`/`cosh` go through `unwrap_fp`, so a huge-|x| overflow saturates to the directed
527    // endpoint: outward (Up) → ±∞ (cosh) / sign·∞ (sinh), inward (Zero) → the largest finite.
528    #[test]
529    fn test_sinh_cosh_directed_overflow() {
530        let p = 53;
531        let max_sig = (IBig::ONE << p) - IBig::ONE;
532        let huge = FBig::<mode::HalfEven, 2>::from_parts(IBig::ONE << 63, 0)
533            .with_precision(p)
534            .value();
535
536        let sinh_up = huge.clone().with_rounding::<mode::Up>().sinh();
537        let sinh_zero = huge.clone().with_rounding::<mode::Zero>().sinh();
538        assert!(
539            sinh_up.repr().is_infinite() && sinh_up.repr().sign() == Sign::Positive,
540            "sinh Up -> +∞"
541        );
542        assert_eq!(sinh_zero.repr().significand(), &max_sig, "sinh Zero -> largest finite");
543        assert_eq!(sinh_zero.repr().exponent(), isize::MAX);
544
545        let cosh_up = huge.clone().with_rounding::<mode::Up>().cosh();
546        let cosh_zero = huge.clone().with_rounding::<mode::Zero>().cosh();
547        assert!(
548            cosh_up.repr().is_infinite() && cosh_up.repr().sign() == Sign::Positive,
549            "cosh Up -> +∞"
550        );
551        assert_eq!(cosh_zero.repr().significand(), &max_sig, "cosh Zero -> largest finite");
552        assert_eq!(cosh_zero.repr().exponent(), isize::MAX);
553
554        // Negative huge: this is the case the closure's sign remap exists for — `exp_m1(−x)`
555        // overflows carrying a positive sign that sinh must flip to negative (and cosh must leave
556        // positive). Under `Up`, a negative overflow rounds inward (largest finite negative) while a
557        // positive overflow reaches +∞.
558        let neg_max_sig = -max_sig.clone();
559        let sinh_neg_up = (-huge.clone()).with_rounding::<mode::Up>().sinh();
560        assert_eq!(sinh_neg_up.repr().sign(), Sign::Negative, "sinh(−huge) sign");
561        assert_eq!(
562            sinh_neg_up.repr().significand(),
563            &neg_max_sig,
564            "sinh(−huge) Up -> largest finite"
565        );
566        assert_eq!(sinh_neg_up.repr().exponent(), isize::MAX);
567        let cosh_neg_up = (-huge.clone()).with_rounding::<mode::Up>().cosh();
568        assert!(
569            cosh_neg_up.repr().is_infinite() && cosh_neg_up.repr().sign() == Sign::Positive,
570            "cosh(−huge) Up -> +∞"
571        );
572
573        // sinh_cosh(−huge) = (largest finite negative, +∞) under Up — per-slot sign remap.
574        let (sh, ch) = (-huge.clone()).with_rounding::<mode::Up>().sinh_cosh();
575        assert_eq!(sh.repr().sign(), Sign::Negative, "sinh_cosh[0](−huge) sign");
576        assert_eq!(
577            sh.repr().significand(),
578            &neg_max_sig,
579            "sinh_cosh[0](−huge) Up -> largest finite"
580        );
581        assert!(
582            ch.repr().is_infinite() && ch.repr().sign() == Sign::Positive,
583            "sinh_cosh[1](−huge) -> +∞"
584        );
585    }
586}