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