Skip to main content

decimal_scaled/
trig.rs

1//! Trigonometric, hyperbolic, and angle-conversion methods for [`I128`].
2//!
3//! # Methods
4//!
5//! Fifteen methods, all routed through the f64 bridge:
6//!
7//! - **Forward trig (radians input):** [`I128::sin`] / [`I128::cos`] /
8//!   [`I128::tan`].
9//! - **Inverse trig (returns radians):** [`I128::asin`] / [`I128::acos`]
10//!   / [`I128::atan`] / [`I128::atan2`].
11//! - **Hyperbolic:** [`I128::sinh`] / [`I128::cosh`] / [`I128::tanh`] /
12//!   [`I128::asinh`] / [`I128::acosh`] / [`I128::atanh`].
13//! - **Angle conversions:** [`I128::to_degrees`] / [`I128::to_radians`].
14//!
15//! # Feature gating
16//!
17//! Every method here calls an inherent `f64` method (`f64::sin`,
18//! `f64::cos`, `f64::tan`, `f64::asin`, `f64::acos`, `f64::atan`,
19//! `f64::atan2`, `f64::sinh`, `f64::cosh`, `f64::tanh`, `f64::asinh`,
20//! `f64::acosh`, `f64::atanh`, `f64::to_degrees`, `f64::to_radians`),
21//! which are only available in `std` — they delegate to platform or
22//! hardware intrinsics that are not in `core`. The whole module is
23//! gated `#[cfg(feature = "std")]` at the `mod trig;` declaration in
24//! `lib.rs` rather than repeating the gate on each method.
25//!
26//! `no_std` users that need trigonometric or hyperbolic functions can
27//! compose them externally via `libm` or hardware-specific intrinsics.
28//!
29//! # Precision
30//!
31//! All methods in this module are **Lossy**: the `I128` value is
32//! converted to `f64` via `to_f64_lossy`, the corresponding `f64`
33//! intrinsic is applied, and the result is converted back via
34//! `from_f64_lossy`. The f64 round-trip introduces up to one LSB of
35//! quantisation error per conversion step.
36//!
37//! IEEE 754 mandates correct rounding for `f64::sqrt` but not for
38//! transcendental functions. In practice mainstream libm implementations
39//! (glibc, msvcrt, macOS libm, musl) produce bit-identical results for
40//! identical inputs, so results are bit-deterministic on supported
41//! platforms in practice.
42//!
43//! # `atan2` signature
44//!
45//! `f64::atan2(self, other)` treats `self` as `y` and `other` as `x`.
46//! This module matches that signature exactly so generic numeric code
47//! calling `y.atan2(x)` works with `T = I128`.
48
49use crate::core_type::I128;
50
51impl<const SCALE: u32> I128<SCALE> {
52    // ── Forward trig (radians input) ──────────────────────────────────
53
54    /// Sine of `self`, where `self` is in radians.
55    ///
56    /// # Precision
57    ///
58    /// Lossy: involves f64 at some point; result may lose precision.
59    ///
60    /// # Examples
61    ///
62    /// ```ignore
63    /// # #[cfg(feature = "std")]
64    /// # {
65    /// use decimal_scaled::I128s12;
66    /// // sin(0) == 0 (bit-exact: f64::sin(0.0) == 0.0).
67    /// assert_eq!(I128s12::ZERO.sin(), I128s12::ZERO);
68    /// # }
69    /// ```
70    #[inline]
71    #[must_use]
72    pub fn sin(self) -> Self {
73        Self::from_f64_lossy(self.to_f64_lossy().sin())
74    }
75
76    /// Cosine of `self`, where `self` is in radians.
77    ///
78    /// # Precision
79    ///
80    /// Lossy: involves f64 at some point; result may lose precision.
81    ///
82    /// # Examples
83    ///
84    /// ```ignore
85    /// # #[cfg(feature = "std")]
86    /// # {
87    /// use decimal_scaled::I128s12;
88    /// // cos(0) == 1 (bit-exact: f64::cos(0.0) == 1.0).
89    /// assert_eq!(I128s12::ZERO.cos(), I128s12::ONE);
90    /// # }
91    /// ```
92    #[inline]
93    #[must_use]
94    pub fn cos(self) -> Self {
95        Self::from_f64_lossy(self.to_f64_lossy().cos())
96    }
97
98    /// Tangent of `self`, where `self` is in radians.
99    ///
100    /// `f64::tan` returns very large magnitudes near odd multiples of
101    /// `pi/2` and infinity at the limit. Inputs that drive the f64
102    /// result outside `[I128::MIN, I128::MAX]` saturate per
103    /// [`Self::from_f64_lossy`].
104    ///
105    /// # Precision
106    ///
107    /// Lossy: involves f64 at some point; result may lose precision.
108    ///
109    /// # Examples
110    ///
111    /// ```ignore
112    /// # #[cfg(feature = "std")]
113    /// # {
114    /// use decimal_scaled::I128s12;
115    /// // tan(0) == 0 (bit-exact: f64::tan(0.0) == 0.0).
116    /// assert_eq!(I128s12::ZERO.tan(), I128s12::ZERO);
117    /// # }
118    /// ```
119    #[inline]
120    #[must_use]
121    pub fn tan(self) -> Self {
122        Self::from_f64_lossy(self.to_f64_lossy().tan())
123    }
124
125    // ── Inverse trig (returns radians) ────────────────────────────────
126
127    /// Arcsine of `self`. Returns radians in `[-pi/2, pi/2]`.
128    ///
129    /// `f64::asin` returns NaN for inputs outside `[-1, 1]`, which
130    /// [`Self::from_f64_lossy`] maps to `I128::ZERO`.
131    ///
132    /// # Precision
133    ///
134    /// Lossy: involves f64 at some point; result may lose precision.
135    ///
136    /// # Examples
137    ///
138    /// ```ignore
139    /// # #[cfg(feature = "std")]
140    /// # {
141    /// use decimal_scaled::I128s12;
142    /// // asin(0) == 0.
143    /// assert_eq!(I128s12::ZERO.asin(), I128s12::ZERO);
144    /// # }
145    /// ```
146    #[inline]
147    #[must_use]
148    pub fn asin(self) -> Self {
149        Self::from_f64_lossy(self.to_f64_lossy().asin())
150    }
151
152    /// Arccosine of `self`. Returns radians in `[0, pi]`.
153    ///
154    /// `f64::acos` returns NaN for inputs outside `[-1, 1]`, which
155    /// [`Self::from_f64_lossy`] maps to `I128::ZERO`.
156    ///
157    /// # Precision
158    ///
159    /// Lossy: involves f64 at some point; result may lose precision.
160    ///
161    /// # Examples
162    ///
163    /// ```ignore
164    /// # #[cfg(feature = "std")]
165    /// # {
166    /// use decimal_scaled::{I128s12, DecimalConsts};
167    /// // acos(1) == 0.
168    /// assert_eq!(I128s12::ONE.acos(), I128s12::ZERO);
169    /// # }
170    /// ```
171    #[inline]
172    #[must_use]
173    pub fn acos(self) -> Self {
174        Self::from_f64_lossy(self.to_f64_lossy().acos())
175    }
176
177    /// Arctangent of `self`. Returns radians in `(-pi/2, pi/2)`.
178    ///
179    /// Defined for the entire real line.
180    ///
181    /// # Precision
182    ///
183    /// Lossy: involves f64 at some point; result may lose precision.
184    ///
185    /// # Examples
186    ///
187    /// ```ignore
188    /// # #[cfg(feature = "std")]
189    /// # {
190    /// use decimal_scaled::I128s12;
191    /// // atan(0) == 0.
192    /// assert_eq!(I128s12::ZERO.atan(), I128s12::ZERO);
193    /// # }
194    /// ```
195    #[inline]
196    #[must_use]
197    pub fn atan(self) -> Self {
198        Self::from_f64_lossy(self.to_f64_lossy().atan())
199    }
200
201    /// Four-quadrant arctangent of `self` (`y`) over `other` (`x`).
202    /// Returns radians in `(-pi, pi]`.
203    ///
204    /// Signature matches `f64::atan2(self, other)`: the receiver is
205    /// `y` and the argument is `x`.
206    ///
207    /// # Precision
208    ///
209    /// Lossy: involves f64 at some point; result may lose precision.
210    ///
211    /// # Examples
212    ///
213    /// ```ignore
214    /// # #[cfg(feature = "std")]
215    /// # {
216    /// use decimal_scaled::{I128s12, DecimalConsts};
217    /// // atan2(1, 1) ~= pi/4 (45 degrees, first quadrant).
218    /// let one = I128s12::ONE;
219    /// let result = one.atan2(one); // approximately I128s12::quarter_pi()
220    /// # }
221    /// ```
222    #[inline]
223    #[must_use]
224    pub fn atan2(self, other: Self) -> Self {
225        Self::from_f64_lossy(self.to_f64_lossy().atan2(other.to_f64_lossy()))
226    }
227
228    // ── Hyperbolic ────────────────────────────────────────────────────
229
230    /// Hyperbolic sine of `self`.
231    ///
232    /// Defined for the entire real line. Saturates at large magnitudes
233    /// per [`Self::from_f64_lossy`].
234    ///
235    /// # Precision
236    ///
237    /// Lossy: involves f64 at some point; result may lose precision.
238    ///
239    /// # Examples
240    ///
241    /// ```ignore
242    /// # #[cfg(feature = "std")]
243    /// # {
244    /// use decimal_scaled::I128s12;
245    /// // sinh(0) == 0.
246    /// assert_eq!(I128s12::ZERO.sinh(), I128s12::ZERO);
247    /// # }
248    /// ```
249    #[inline]
250    #[must_use]
251    pub fn sinh(self) -> Self {
252        Self::from_f64_lossy(self.to_f64_lossy().sinh())
253    }
254
255    /// Hyperbolic cosine of `self`.
256    ///
257    /// Defined for the entire real line; result is always >= 1.
258    /// Saturates at large magnitudes per [`Self::from_f64_lossy`].
259    ///
260    /// # Precision
261    ///
262    /// Lossy: involves f64 at some point; result may lose precision.
263    ///
264    /// # Examples
265    ///
266    /// ```ignore
267    /// # #[cfg(feature = "std")]
268    /// # {
269    /// use decimal_scaled::I128s12;
270    /// // cosh(0) == 1.
271    /// assert_eq!(I128s12::ZERO.cosh(), I128s12::ONE);
272    /// # }
273    /// ```
274    #[inline]
275    #[must_use]
276    pub fn cosh(self) -> Self {
277        Self::from_f64_lossy(self.to_f64_lossy().cosh())
278    }
279
280    /// Hyperbolic tangent of `self`.
281    ///
282    /// Defined for the entire real line; range is `(-1, 1)`.
283    ///
284    /// # Precision
285    ///
286    /// Lossy: involves f64 at some point; result may lose precision.
287    ///
288    /// # Examples
289    ///
290    /// ```ignore
291    /// # #[cfg(feature = "std")]
292    /// # {
293    /// use decimal_scaled::I128s12;
294    /// // tanh(0) == 0.
295    /// assert_eq!(I128s12::ZERO.tanh(), I128s12::ZERO);
296    /// # }
297    /// ```
298    #[inline]
299    #[must_use]
300    pub fn tanh(self) -> Self {
301        Self::from_f64_lossy(self.to_f64_lossy().tanh())
302    }
303
304    /// Inverse hyperbolic sine of `self`.
305    ///
306    /// Defined for the entire real line.
307    ///
308    /// # Precision
309    ///
310    /// Lossy: involves f64 at some point; result may lose precision.
311    ///
312    /// # Examples
313    ///
314    /// ```ignore
315    /// # #[cfg(feature = "std")]
316    /// # {
317    /// use decimal_scaled::I128s12;
318    /// // asinh(0) == 0.
319    /// assert_eq!(I128s12::ZERO.asinh(), I128s12::ZERO);
320    /// # }
321    /// ```
322    #[inline]
323    #[must_use]
324    pub fn asinh(self) -> Self {
325        Self::from_f64_lossy(self.to_f64_lossy().asinh())
326    }
327
328    /// Inverse hyperbolic cosine of `self`.
329    ///
330    /// `f64::acosh` returns NaN for inputs less than 1, which
331    /// [`Self::from_f64_lossy`] maps to `I128::ZERO`.
332    ///
333    /// # Precision
334    ///
335    /// Lossy: involves f64 at some point; result may lose precision.
336    ///
337    /// # Examples
338    ///
339    /// ```ignore
340    /// # #[cfg(feature = "std")]
341    /// # {
342    /// use decimal_scaled::I128s12;
343    /// // acosh(1) == 0.
344    /// assert_eq!(I128s12::ONE.acosh(), I128s12::ZERO);
345    /// # }
346    /// ```
347    #[inline]
348    #[must_use]
349    pub fn acosh(self) -> Self {
350        Self::from_f64_lossy(self.to_f64_lossy().acosh())
351    }
352
353    /// Inverse hyperbolic tangent of `self`.
354    ///
355    /// `f64::atanh` returns NaN for inputs outside `(-1, 1)`, which
356    /// [`Self::from_f64_lossy`] maps to `I128::ZERO`.
357    ///
358    /// # Precision
359    ///
360    /// Lossy: involves f64 at some point; result may lose precision.
361    ///
362    /// # Examples
363    ///
364    /// ```ignore
365    /// # #[cfg(feature = "std")]
366    /// # {
367    /// use decimal_scaled::I128s12;
368    /// // atanh(0) == 0.
369    /// assert_eq!(I128s12::ZERO.atanh(), I128s12::ZERO);
370    /// # }
371    /// ```
372    #[inline]
373    #[must_use]
374    pub fn atanh(self) -> Self {
375        Self::from_f64_lossy(self.to_f64_lossy().atanh())
376    }
377
378    // ── Angle conversions ─────────────────────────────────────────────
379
380    /// Convert radians to degrees: `self * (180 / pi)`.
381    ///
382    /// Routed through `f64::to_degrees` so results match the de facto
383    /// reference produced by the rest of the Rust ecosystem. Multiplying
384    /// by a precomputed `I128` factor derived from `I128::pi()` would
385    /// diverge from f64 by a 1-LSB rescale rounding without any
386    /// practical determinism gain, since the f64 bridge is already the
387    /// precision floor.
388    ///
389    /// # Precision
390    ///
391    /// Lossy: involves f64 at some point; result may lose precision.
392    ///
393    /// # Examples
394    ///
395    /// ```ignore
396    /// # #[cfg(feature = "std")]
397    /// # {
398    /// use decimal_scaled::I128s12;
399    /// // to_degrees(0) == 0.
400    /// assert_eq!(I128s12::ZERO.to_degrees(), I128s12::ZERO);
401    /// # }
402    /// ```
403    #[inline]
404    #[must_use]
405    pub fn to_degrees(self) -> Self {
406        Self::from_f64_lossy(self.to_f64_lossy().to_degrees())
407    }
408
409    /// Convert degrees to radians: `self * (pi / 180)`.
410    ///
411    /// Routed through `f64::to_radians`. See [`Self::to_degrees`] for
412    /// the rationale.
413    ///
414    /// # Precision
415    ///
416    /// Lossy: involves f64 at some point; result may lose precision.
417    ///
418    /// # Examples
419    ///
420    /// ```ignore
421    /// # #[cfg(feature = "std")]
422    /// # {
423    /// use decimal_scaled::I128s12;
424    /// // to_radians(0) == 0.
425    /// assert_eq!(I128s12::ZERO.to_radians(), I128s12::ZERO);
426    /// # }
427    /// ```
428    #[inline]
429    #[must_use]
430    pub fn to_radians(self) -> Self {
431        Self::from_f64_lossy(self.to_f64_lossy().to_radians())
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use crate::consts::DecimalConsts;
438    use crate::core_type::I128s12;
439
440    // Allow 2 LSB of tolerance for single f64 round-trip operations.
441    const TWO_LSB: i128 = 2;
442
443    // Allow 4 LSB of tolerance for operations that chain multiple trig
444    // calls, each adding up to 1 LSB of quantisation slack.
445    const FOUR_LSB: i128 = 4;
446
447    // Allow 32 LSB when comparing angle-conversion results against exact
448    // integer targets (180, 90, 45 degrees). The I128::pi() constant has
449    // more digits than f64 can represent; the rounding error multiplies
450    // by ~57.3 during the degrees conversion, landing within ~30 LSB of
451    // the exact integer at SCALE = 12.
452    const ANGLE_TOLERANCE_LSB: i128 = 32;
453
454    fn within_lsb(actual: I128s12, expected: I128s12, lsb: i128) -> bool {
455        let diff = (actual.to_bits() - expected.to_bits()).abs();
456        diff <= lsb
457    }
458
459    // ── Forward trig ──────────────────────────────────────────────────
460
461    /// `sin(0) == 0` -- bit-exact via `f64::sin(0.0) == 0.0`.
462    #[test]
463    fn sin_zero_is_zero() {
464        assert_eq!(I128s12::ZERO.sin(), I128s12::ZERO);
465    }
466
467    /// `cos(0) == 1` -- bit-exact via `f64::cos(0.0) == 1.0`.
468    #[test]
469    fn cos_zero_is_one() {
470        assert_eq!(I128s12::ZERO.cos(), I128s12::ONE);
471    }
472
473    /// `tan(0) == 0` -- bit-exact via `f64::tan(0.0) == 0.0`.
474    #[test]
475    fn tan_zero_is_zero() {
476        assert_eq!(I128s12::ZERO.tan(), I128s12::ZERO);
477    }
478
479    /// Pythagorean identity: `sin^2(x) + cos^2(x) ~= 1` within 4 LSB
480    /// for representative values of `x`. Values are chosen to be well
481    /// away from any well-known mathematical constant.
482    #[test]
483    fn sin_squared_plus_cos_squared_is_one() {
484        for raw in [
485            1_234_567_890_123_i128,  // ~1.234567...
486            -2_345_678_901_234_i128, // ~-2.345678...
487            500_000_000_000_i128,    // 0.5
488            -500_000_000_000_i128,   // -0.5
489            4_567_891_234_567_i128,  // ~4.567891...
490        ] {
491            let x = I128s12::from_bits(raw);
492            let s = x.sin();
493            let c = x.cos();
494            let sum = (s * s) + (c * c);
495            assert!(
496                within_lsb(sum, I128s12::ONE, FOUR_LSB),
497                "sin^2 + cos^2 != 1 for raw={raw}: got bits {} (delta {})",
498                sum.to_bits(),
499                (sum.to_bits() - I128s12::ONE.to_bits()).abs(),
500            );
501        }
502    }
503
504    // ── Inverse trig ──────────────────────────────────────────────────
505
506    /// `asin(0) == 0` -- bit-exact.
507    #[test]
508    fn asin_zero_is_zero() {
509        assert_eq!(I128s12::ZERO.asin(), I128s12::ZERO);
510    }
511
512    /// `acos(1) == 0` -- bit-exact via `f64::acos(1.0) == 0.0`.
513    #[test]
514    fn acos_one_is_zero() {
515        assert_eq!(I128s12::ONE.acos(), I128s12::ZERO);
516    }
517
518    /// `acos(0) ~= pi/2` within 4 LSB.
519    #[test]
520    fn acos_zero_is_half_pi() {
521        let result = I128s12::ZERO.acos();
522        assert!(
523            within_lsb(result, I128s12::half_pi(), FOUR_LSB),
524            "acos(0) bits {}, half_pi bits {}",
525            result.to_bits(),
526            I128s12::half_pi().to_bits(),
527        );
528    }
529
530    /// `atan(0) == 0` -- bit-exact via `f64::atan(0.0) == 0.0`.
531    #[test]
532    fn atan_zero_is_zero() {
533        assert_eq!(I128s12::ZERO.atan(), I128s12::ZERO);
534    }
535
536    /// Round-trip identity: `asin(sin(x)) ~= x` for `x` in
537    /// `[-pi/2, pi/2]`. Values stay within the principal branch.
538    #[test]
539    fn asin_of_sin_round_trip() {
540        for raw in [
541            123_456_789_012_i128,    // ~0.123456...
542            -123_456_789_012_i128,   // ~-0.123456...
543            456_789_012_345_i128,    // ~0.456789...
544            -456_789_012_345_i128,   // ~-0.456789...
545            1_234_567_890_123_i128,  // ~1.234567... (well inside pi/2)
546            -1_234_567_890_123_i128, // ~-1.234567...
547        ] {
548            let x = I128s12::from_bits(raw);
549            let recovered = x.sin().asin();
550            assert!(
551                within_lsb(recovered, x, FOUR_LSB),
552                "asin(sin(x)) != x for raw={raw}: got bits {} (delta {})",
553                recovered.to_bits(),
554                (recovered.to_bits() - x.to_bits()).abs(),
555            );
556        }
557    }
558
559    // ── atan2 ─────────────────────────────────────────────────────────
560
561    /// `atan2(1, 1) ~= pi/4` (first-quadrant 45 degrees).
562    #[test]
563    fn atan2_first_quadrant_diagonal() {
564        let one = I128s12::ONE;
565        let result = one.atan2(one);
566        assert!(
567            within_lsb(result, I128s12::quarter_pi(), TWO_LSB),
568            "atan2(1, 1) bits {}, quarter_pi bits {}",
569            result.to_bits(),
570            I128s12::quarter_pi().to_bits(),
571        );
572    }
573
574    /// `atan2(-1, -1) ~= -3*pi/4` (third-quadrant correctness).
575    #[test]
576    fn atan2_third_quadrant_diagonal() {
577        let neg_one = -I128s12::ONE;
578        let result = neg_one.atan2(neg_one);
579        let three = I128s12::from_int(3);
580        let expected = -(I128s12::quarter_pi() * three);
581        assert!(
582            within_lsb(result, expected, TWO_LSB),
583            "atan2(-1, -1) bits {}, expected -3pi/4 bits {}",
584            result.to_bits(),
585            expected.to_bits(),
586        );
587    }
588
589    /// `atan2(1, -1) ~= 3*pi/4` (second-quadrant correctness).
590    #[test]
591    fn atan2_second_quadrant_diagonal() {
592        let one = I128s12::ONE;
593        let neg_one = -I128s12::ONE;
594        let result = one.atan2(neg_one);
595        let three = I128s12::from_int(3);
596        let expected = I128s12::quarter_pi() * three;
597        assert!(
598            within_lsb(result, expected, TWO_LSB),
599            "atan2(1, -1) bits {}, expected 3pi/4 bits {}",
600            result.to_bits(),
601            expected.to_bits(),
602        );
603    }
604
605    /// `atan2(-1, 1) ~= -pi/4` (fourth-quadrant correctness).
606    #[test]
607    fn atan2_fourth_quadrant_diagonal() {
608        let one = I128s12::ONE;
609        let neg_one = -I128s12::ONE;
610        let result = neg_one.atan2(one);
611        let expected = -I128s12::quarter_pi();
612        assert!(
613            within_lsb(result, expected, TWO_LSB),
614            "atan2(-1, 1) bits {}, expected -pi/4 bits {}",
615            result.to_bits(),
616            expected.to_bits(),
617        );
618    }
619
620    /// `atan2(0, 1) == 0` (positive x-axis is bit-exact).
621    #[test]
622    fn atan2_positive_x_axis_is_zero() {
623        let zero = I128s12::ZERO;
624        let one = I128s12::ONE;
625        assert_eq!(zero.atan2(one), I128s12::ZERO);
626    }
627
628    // ── Hyperbolic ────────────────────────────────────────────────────
629
630    /// `sinh(0) == 0` -- bit-exact via `f64::sinh(0.0) == 0.0`.
631    #[test]
632    fn sinh_zero_is_zero() {
633        assert_eq!(I128s12::ZERO.sinh(), I128s12::ZERO);
634    }
635
636    /// `cosh(0) == 1` -- bit-exact via `f64::cosh(0.0) == 1.0`.
637    #[test]
638    fn cosh_zero_is_one() {
639        assert_eq!(I128s12::ZERO.cosh(), I128s12::ONE);
640    }
641
642    /// `tanh(0) == 0` -- bit-exact via `f64::tanh(0.0) == 0.0`.
643    #[test]
644    fn tanh_zero_is_zero() {
645        assert_eq!(I128s12::ZERO.tanh(), I128s12::ZERO);
646    }
647
648    /// `asinh(0) == 0` -- bit-exact.
649    #[test]
650    fn asinh_zero_is_zero() {
651        assert_eq!(I128s12::ZERO.asinh(), I128s12::ZERO);
652    }
653
654    /// `acosh(1) == 0` -- bit-exact via `f64::acosh(1.0) == 0.0`.
655    #[test]
656    fn acosh_one_is_zero() {
657        assert_eq!(I128s12::ONE.acosh(), I128s12::ZERO);
658    }
659
660    /// `atanh(0) == 0` -- bit-exact.
661    #[test]
662    fn atanh_zero_is_zero() {
663        assert_eq!(I128s12::ZERO.atanh(), I128s12::ZERO);
664    }
665
666    /// Identity: `cosh^2(x) - sinh^2(x) == 1` within 4 LSB for
667    /// representative values of `x`.
668    #[test]
669    fn cosh_squared_minus_sinh_squared_is_one() {
670        for raw in [
671            500_000_000_000_i128,    // 0.5
672            -500_000_000_000_i128,   // -0.5
673            1_234_567_890_123_i128,  // ~1.234567
674            -1_234_567_890_123_i128, // ~-1.234567
675            2_500_000_000_000_i128,  // 2.5
676        ] {
677            let x = I128s12::from_bits(raw);
678            let ch = x.cosh();
679            let sh = x.sinh();
680            let diff = (ch * ch) - (sh * sh);
681            assert!(
682                within_lsb(diff, I128s12::ONE, FOUR_LSB),
683                "cosh^2 - sinh^2 != 1 for raw={raw}: got bits {} (delta {})",
684                diff.to_bits(),
685                (diff.to_bits() - I128s12::ONE.to_bits()).abs(),
686            );
687        }
688    }
689
690    // ── Angle conversions ─────────────────────────────────────────────
691
692    /// `to_degrees(pi) ~= 180` within `ANGLE_TOLERANCE_LSB`. The
693    /// tolerance is dominated by f64's limited precision on `pi`,
694    /// amplified by ~57.3 during the degrees conversion.
695    #[test]
696    fn to_degrees_pi_is_180() {
697        let pi = I128s12::pi();
698        let result = pi.to_degrees();
699        let expected = I128s12::from_int(180);
700        assert!(
701            within_lsb(result, expected, ANGLE_TOLERANCE_LSB),
702            "to_degrees(pi) bits {}, expected 180 bits {} (delta {})",
703            result.to_bits(),
704            expected.to_bits(),
705            (result.to_bits() - expected.to_bits()).abs(),
706        );
707    }
708
709    /// `to_radians(180) ~= pi` within `ANGLE_TOLERANCE_LSB`.
710    #[test]
711    fn to_radians_180_is_pi() {
712        let one_eighty = I128s12::from_int(180);
713        let result = one_eighty.to_radians();
714        let expected = I128s12::pi();
715        assert!(
716            within_lsb(result, expected, ANGLE_TOLERANCE_LSB),
717            "to_radians(180) bits {}, expected pi bits {} (delta {})",
718            result.to_bits(),
719            expected.to_bits(),
720            (result.to_bits() - expected.to_bits()).abs(),
721        );
722    }
723
724    /// `to_degrees(0) == 0` -- bit-exact (0 * anything == 0).
725    #[test]
726    fn to_degrees_zero_is_zero() {
727        assert_eq!(I128s12::ZERO.to_degrees(), I128s12::ZERO);
728    }
729
730    /// `to_radians(0) == 0` -- bit-exact.
731    #[test]
732    fn to_radians_zero_is_zero() {
733        assert_eq!(I128s12::ZERO.to_radians(), I128s12::ZERO);
734    }
735
736    /// Round-trip: `to_radians(to_degrees(x)) ~= x` within 4 LSB
737    /// (two f64 round-trips).
738    #[test]
739    fn to_radians_to_degrees_round_trip() {
740        for raw in [
741            500_000_000_000_i128,    // 0.5
742            -500_000_000_000_i128,   // -0.5
743            1_234_567_890_123_i128,  // ~1.234567
744            -2_345_678_901_234_i128, // ~-2.345678
745        ] {
746            let x = I128s12::from_bits(raw);
747            let recovered = x.to_degrees().to_radians();
748            assert!(
749                within_lsb(recovered, x, FOUR_LSB),
750                "to_radians(to_degrees(x)) != x for raw={raw}: got bits {} (delta {})",
751                recovered.to_bits(),
752                (recovered.to_bits() - x.to_bits()).abs(),
753            );
754        }
755    }
756
757    /// `to_degrees(half_pi) ~= 90` within `ANGLE_TOLERANCE_LSB`.
758    #[test]
759    fn to_degrees_half_pi_is_90() {
760        let result = I128s12::half_pi().to_degrees();
761        let expected = I128s12::from_int(90);
762        assert!(
763            within_lsb(result, expected, ANGLE_TOLERANCE_LSB),
764            "to_degrees(half_pi) bits {}, expected 90 bits {} (delta {})",
765            result.to_bits(),
766            expected.to_bits(),
767            (result.to_bits() - expected.to_bits()).abs(),
768        );
769    }
770
771    /// `to_degrees(quarter_pi) ~= 45` within `ANGLE_TOLERANCE_LSB`.
772    #[test]
773    fn to_degrees_quarter_pi_is_45() {
774        let result = I128s12::quarter_pi().to_degrees();
775        let expected = I128s12::from_int(45);
776        assert!(
777            within_lsb(result, expected, ANGLE_TOLERANCE_LSB),
778            "to_degrees(quarter_pi) bits {}, expected 45 bits {} (delta {})",
779            result.to_bits(),
780            expected.to_bits(),
781            (result.to_bits() - expected.to_bits()).abs(),
782        );
783    }
784
785    // ── Cross-method consistency ──────────────────────────────────────
786
787    /// `tan(x) ~= sin(x) / cos(x)` within 4 LSB for `x` away from
788    /// odd multiples of `pi/2`.
789    #[test]
790    fn tan_matches_sin_over_cos() {
791        for raw in [
792            500_000_000_000_i128,    // 0.5
793            -500_000_000_000_i128,   // -0.5
794            1_000_000_000_000_i128,  // 1.0 (cos(1.0) ~= 0.54, safe)
795            -1_000_000_000_000_i128, // -1.0
796            123_456_789_012_i128,    // ~0.123456
797        ] {
798            let x = I128s12::from_bits(raw);
799            let t = x.tan();
800            let sc = x.sin() / x.cos();
801            assert!(
802                within_lsb(t, sc, FOUR_LSB),
803                "tan(x) != sin/cos for raw={raw}: tan bits {}, sin/cos bits {}",
804                t.to_bits(),
805                sc.to_bits(),
806            );
807        }
808    }
809
810    /// `tanh(x) ~= sinh(x) / cosh(x)` within 4 LSB. `cosh` is always
811    /// positive so there is no divide-by-zero risk.
812    #[test]
813    fn tanh_matches_sinh_over_cosh() {
814        for raw in [
815            500_000_000_000_i128,    // 0.5
816            -500_000_000_000_i128,   // -0.5
817            1_234_567_890_123_i128,  // ~1.234567
818            -2_345_678_901_234_i128, // ~-2.345678
819        ] {
820            let x = I128s12::from_bits(raw);
821            let t = x.tanh();
822            let sc = x.sinh() / x.cosh();
823            assert!(
824                within_lsb(t, sc, FOUR_LSB),
825                "tanh(x) != sinh/cosh for raw={raw}: tanh bits {}, sinh/cosh bits {}",
826                t.to_bits(),
827                sc.to_bits(),
828            );
829        }
830    }
831}