From 6762cdc8fc411b8dfc96dabad1eb4161d03e4184 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Sat, 18 Jul 2026 23:38:05 +0800 Subject: [PATCH 01/21] Initial impl for Ziv loop --- complex/CHANGELOG.md | 6 + complex/src/cbig_cached_ops.rs | 26 ++-- complex/src/exp.rs | 78 +++++----- complex/src/log.rs | 6 +- complex/src/math/trig.rs | 6 +- float/CHANGELOG.md | 17 +++ float/src/convert.rs | 29 +++- float/src/exp.rs | 221 ++++++++++++++++------------ float/src/fbig_cached_ops.rs | 68 +++++---- float/src/lib.rs | 1 + float/src/log.rs | 201 +++++++++++++++---------- float/src/math/cache.rs | 12 +- float/src/math/hyper.rs | 6 +- float/src/third_party/num_traits.rs | 9 +- float/src/ziv.rs | 191 ++++++++++++++++++++++++ float/tests/exp.rs | 4 +- float/tests/exp_log_root_prop.rs | 38 ++++- float/tests/log.rs | 4 +- guide-zh/src/compliance.md | 4 +- guide-zh/src/faq.md | 2 +- guide/src/compliance.md | 4 +- guide/src/faq.md | 2 +- 22 files changed, 652 insertions(+), 283 deletions(-) create mode 100644 float/src/ziv.rs diff --git a/complex/CHANGELOG.md b/complex/CHANGELOG.md index 4afe6d11..cb73c2c3 100644 --- a/complex/CHANGELOG.md +++ b/complex/CHANGELOG.md @@ -8,6 +8,12 @@ macro now works in `const` position for coefficients that fit in a `DoubleWord`; larger coefficients fall back to the runtime heap path. +### Change +- **(breaking, bound)** The complex transcendentals (`exp`, `ln`, `powf`, `sin`/`cos`/`tan`/ + `sin_cos`, `asin`/`acos`/`atan`) and their `CachedCBig` forwarders now require `R: ErrorBounds`, + inherited from `dashu-float`'s Ziv-backed real `exp`/`ln`/`sinh_cosh` primitives. `powi` and the + field arithmetic (`add`/`sub`/`mul`/`div`/`sqr`/`inv`) remain `R: Round`. + ## 0.5.0 (Initial release) `dashu-cmplx` provides [`CBig`], an arbitrary-precision complex number type built on top of diff --git a/complex/src/cbig_cached_ops.rs b/complex/src/cbig_cached_ops.rs index 1d913bde..99ec59cd 100644 --- a/complex/src/cbig_cached_ops.rs +++ b/complex/src/cbig_cached_ops.rs @@ -11,7 +11,7 @@ use core::iter::{Product, Sum}; use core::ops::{Add, AddAssign, Div, DivAssign, Mul, MulAssign, Neg, Sub, SubAssign}; use dashu_base::Inverse; -use dashu_float::round::Round; +use dashu_float::round::{ErrorBounds, Round}; use dashu_float::FBig; use dashu_int::{IBig, Word}; @@ -372,16 +372,6 @@ macro_rules! delegate_to_cbig { } impl CachedCBig { - // cache-threading transcendentals - forward_cached!(ln => log); - forward_cached!(exp => exp); - forward_cached!(sin => sin); - forward_cached!(cos => cos); - forward_cached!(tan => tan); - forward_cached!(asin => asin); - forward_cached!(acos => acos); - forward_cached!(atan => atan); - // non-cache delegations delegate_to_cbig!(sqr); delegate_to_cbig!(sqrt); @@ -411,6 +401,20 @@ impl CachedCBig { ctx.float() .unwrap_fp(ctx.arg::(&self.cbig, Some(&mut *c))) } +} + +// Transcendentals route through the Ziv-backed (or Ziv-dependent) real/complex methods, which +// require `R: ErrorBounds`. +impl CachedCBig { + // cache-threading transcendentals + forward_cached!(ln => log); + forward_cached!(exp => exp); + forward_cached!(sin => sin); + forward_cached!(cos => cos); + forward_cached!(tan => tan); + forward_cached!(asin => asin); + forward_cached!(acos => acos); + forward_cached!(atan => atan); /// Complex power `self^w` (see [`CBig::powf`]). Threads the cache into the inner `log`/`exp`. #[inline] diff --git a/complex/src/exp.rs b/complex/src/exp.rs index b7aacb02..91cc6f1b 100644 --- a/complex/src/exp.rs +++ b/complex/src/exp.rs @@ -11,7 +11,7 @@ use crate::cbig::CBig; use crate::repr::{combine_parts, exact, reborrow_cache, riemann, CfpResult, Context}; use dashu_base::Approximation::*; use dashu_base::{BitTest, Sign}; -use dashu_float::round::Round; +use dashu_float::round::{ErrorBounds, Round}; use dashu_float::{ConstCache, FBig, FpError}; use dashu_int::{IBig, Word}; @@ -23,6 +23,36 @@ const EXP_GUARD: usize = 14; const POWF_GUARD: usize = 22; impl Context { + /// Raise a complex number to an integer power under this context (context layer), via repeated + /// squaring (branch-cut-free, cheaper than `exp(n·log z)`). No cache. + /// + /// `powi(z, 0) = 1`; a negative exponent computes `powi(z, |n|)` then inverts. + pub fn powi(&self, z: &CBig, exp: IBig) -> CfpResult { + let (sign, n) = exp.into_parts(); + if n.is_zero() { + return Ok(Exact(CBig::ONE)); + } + let negative = sign == Sign::Negative; + let bitlen = n.bit_len(); + // left-to-right binary exponentiation, starting from the leading set bit + let mut acc = z.clone(); + for i in (0..bitlen - 1).rev() { + acc = self.sqr(&acc)?.value(); + if n.bit(i) { + acc = self.mul(&acc, z)?.value(); + } + } + // The intermediate rounding flags are folded away (the value is near-correctly rounded); + // for a negative exponent the final `inv` carries its own flags. + if negative { + self.inv(&acc) + } else { + Ok(Exact(acc)) + } + } +} + +impl Context { /// Complex exponential under this context (context layer). Reuses `dashu-float`'s `exp` and /// `sin_cos`; the cache is threaded into both (the convenience layer passes `None`). /// @@ -59,34 +89,6 @@ impl Context { Ok(combine_parts(re, im)) } - /// Raise a complex number to an integer power under this context (context layer), via repeated - /// squaring (branch-cut-free, cheaper than `exp(n·log z)`). No cache. - /// - /// `powi(z, 0) = 1`; a negative exponent computes `powi(z, |n|)` then inverts. - pub fn powi(&self, z: &CBig, exp: IBig) -> CfpResult { - let (sign, n) = exp.into_parts(); - if n.is_zero() { - return Ok(Exact(CBig::ONE)); - } - let negative = sign == Sign::Negative; - let bitlen = n.bit_len(); - // left-to-right binary exponentiation, starting from the leading set bit - let mut acc = z.clone(); - for i in (0..bitlen - 1).rev() { - acc = self.sqr(&acc)?.value(); - if n.bit(i) { - acc = self.mul(&acc, z)?.value(); - } - } - // The intermediate rounding flags are folded away (the value is near-correctly rounded); - // for a negative exponent the final `inv` carries its own flags. - if negative { - self.inv(&acc) - } else { - Ok(Exact(acc)) - } - } - /// Raise `base` to a complex power under this context (context layer): `exp(w·log base)` on the /// principal branch, evaluated at `p + POWF_GUARD` and re-rounded. `powf(0, 0) = 1` (matching /// `FBig::powf`). @@ -114,24 +116,26 @@ impl Context { } impl CBig { - /// Complex exponential `e^z` (convenience layer). + /// Integer power (convenience layer). /// /// # Panics /// - /// Panics if the precision is unlimited or on an indeterminate special value. + /// Panics on an indeterminate / out-of-domain result (e.g. `0⁻¹`). #[inline] - pub fn exp(&self) -> Self { - self.context().unwrap_cfp(self.context().exp(self, None)) + pub fn powi(&self, exp: IBig) -> Self { + self.context().unwrap_cfp(self.context().powi(self, exp)) } +} - /// Integer power (convenience layer). +impl CBig { + /// Complex exponential `e^z` (convenience layer). /// /// # Panics /// - /// Panics on an indeterminate / out-of-domain result (e.g. `0⁻¹`). + /// Panics if the precision is unlimited or on an indeterminate special value. #[inline] - pub fn powi(&self, exp: IBig) -> Self { - self.context().unwrap_cfp(self.context().powi(self, exp)) + pub fn exp(&self) -> Self { + self.context().unwrap_cfp(self.context().exp(self, None)) } /// Complex power `self^w` (convenience layer). diff --git a/complex/src/log.rs b/complex/src/log.rs index d79fb9d5..8e08664a 100644 --- a/complex/src/log.rs +++ b/complex/src/log.rs @@ -2,14 +2,14 @@ use crate::cbig::CBig; use crate::repr::{combine_parts, exact, reborrow_cache, riemann, CfpResult, Context}; -use dashu_float::round::Round; +use dashu_float::round::ErrorBounds; use dashu_float::{ConstCache, FBig, Repr}; use dashu_int::Word; /// Guard digits (base-B) for `log`. Composes `hypot` (for `|z|`), `ln`, and `atan2`. const LOG_GUARD: usize = 14; -impl Context { +impl Context { /// Complex natural logarithm under this context (context layer). `log z = ln|z| + i·arg(z)`, /// with the imaginary part in `]−π, π]`. The cache threads into `ln` and `atan2`. /// @@ -46,7 +46,7 @@ impl Context { } } -impl CBig { +impl CBig { /// Complex natural logarithm (principal branch; convenience layer). /// /// # Panics diff --git a/complex/src/math/trig.rs b/complex/src/math/trig.rs index 9f3fabfb..3d3d7a53 100644 --- a/complex/src/math/trig.rs +++ b/complex/src/math/trig.rs @@ -6,7 +6,7 @@ use crate::cbig::CBig; use crate::repr::{combine_parts, reborrow_cache, CfpResult, Context}; -use dashu_float::round::Round; +use dashu_float::round::ErrorBounds; use dashu_float::{ConstCache, FBig, FpError, Repr}; use dashu_int::{IBig, Word}; @@ -14,7 +14,7 @@ use dashu_int::{IBig, Word}; /// products; the cancellation near the trig zeros is absorbed by the re-round. const TRIG_GUARD: usize = 16; -impl Context { +impl Context { /// Simultaneously compute `sin z` and `cos z` (context layer). Returns `(sin, cos)` each as a /// [`CfpResult`]. An infinite input maps to [`FpError::Indeterminate`] (the C99 NaN cases). pub fn sin_cos( @@ -194,7 +194,7 @@ impl Context { /// Guard digits (base-B) for the inverse trig (squares, a sqrt, logs, and a divide). const ITRIG_GUARD: usize = 18; -impl CBig { +impl CBig { /// Complex sine (convenience layer). Panics on an indeterminate special value. #[inline] pub fn sin(&self) -> Self { diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index f3c99017..ee15b0e7 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -6,6 +6,23 @@ - `Repr::new_const`: a `const`-evaluable, normalized `Repr` constructor from a `DoubleWord` significand (the `const` counterpart of `Repr::new`). `FBig::from_parts_const` now delegates to it, and the complex literal macro uses it. +- **Guaranteed-correct rounding for `exp`, `exp_m1`, `ln`, `ln_1p`** via a Ziv retry loop. A generic + `Context::ziv` driver rounds the working-precision approximation to the target precision and + verifies, against the `ErrorBounds` rounding preimage, that the approximation's provable error + interval lies entirely inside one rounding bin; if not, it retries with more guard digits. The + loop provably terminates and preserves the `Exact`/`Inexact` flag. Series evaluation is factored + into near-correct `ln_compute`/`exp_compute` cores (`R: Round`) that the Ziv wrapper certifies. +- **Tightened `exp` guard digits** (now a performance knob, since Ziv — not the guard count — + guarantees correctness): the `Bⁿ`-powering guard is halved from `2n` to `n` and the series guard + drops its conservative `+ 2`. + +### Change +- **(breaking, bound)** `Context::exp`/`exp_m1`/`ln`/`ln_1p` (and `powf`, the hyperbolic family, + and the base-conversion path that derives from them) now require `R: ErrorBounds` rather than + `R: Round` — the Ziv containment test needs the rounding preimage that `ErrorBounds` provides. + All six built-in modes satisfy `ErrorBounds`; only custom non-`ErrorBounds` `Round` modes are + affected (custom modes are already discouraged). `CachedFBig`/`CachedCBig` forwarders for these + methods carry the same bound. Arithmetic, roots, trigonometric, and `powi` remain `R: Round`. ## 0.5.0 diff --git a/float/src/convert.rs b/float/src/convert.rs index 671f573a..d1606322 100644 --- a/float/src/convert.rs +++ b/float/src/convert.rs @@ -609,11 +609,19 @@ impl Context { // if the exponent is large, then we first estimate the result exponent as floor(exponent * log(B) / log(NewB)), // then the fractional part is multiplied with the original significand let work_context = Context::::new(2 * self.precision); // double the precision to get the precise logarithm + // ln(old base) and ln(new base) — near-correct is sufficient for the exponent estimate, + // and using the near-correct `ln_compute`/`ln_base` (R: Round) keeps base conversion + // off the `ErrorBounds` bound. Both are computed in base NewB so the euclidean division + // has matching bases. let new_exp = repr.exponent - * work_context.unwrap_fp( - work_context - .ln(&Repr::new(Repr::::BASE.into(), 0), reborrow_cache(&mut cache)), - ); + * work_context + .ln_compute::( + &Repr::new(Repr::::BASE.into(), 0), + work_context.precision, + false, + reborrow_cache(&mut cache), + ) + .0; let (exponent, rem) = new_exp.div_rem_euclid(work_context.ln_base::(reborrow_cache(&mut cache))); let exponent_sign = exponent.sign(); @@ -626,7 +634,18 @@ impl Context { ); } }; - let exp_rem = rem.exp(); + // exp(fractional exponent) — near-correct is sufficient (it scales the significand), + // so use `exp_compute` (R: Round) and stay off the `ErrorBounds` bound. + let n = 1usize << (work_context.precision.bit_len() / 2); + let exp_rem = work_context + .exp_compute::( + &rem.repr, + work_context.precision, + false, + n, + reborrow_cache(&mut cache), + ) + .0; let significand = repr.significand * exp_rem.repr.significand; let repr = Repr::new(significand, exponent + exp_rem.repr.exponent); self.repr_round(repr) diff --git a/float/src/exp.rs b/float/src/exp.rs index 9d8152f3..0c9705a6 100644 --- a/float/src/exp.rs +++ b/float/src/exp.rs @@ -5,7 +5,7 @@ use crate::{ fbig::FBig, math::cache::{reborrow_cache, ConstCache}, repr::{Context, Repr, Word}, - round::Round, + round::{ErrorBounds, Round}, utils::ceil_usize, }; use dashu_base::{AbsOrd, Approximation::*, BitTest, DivRemEuclid, EstimatedLog2, Sign}; @@ -28,7 +28,11 @@ impl FBig { pub fn powi(&self, exp: IBig) -> FBig { self.context.unwrap_fp(self.context.powi(&self.repr, exp)) } +} +// `powf`/`exp`/`exp_m1` route through the Ziv-backed Context methods, which require `R: ErrorBounds` +// for their correctness guarantee. +impl FBig { /// Raise the floating point number to an floating point power. /// /// # Examples @@ -186,6 +190,98 @@ impl Context { Ok(res.with_precision(self.precision)) } + /// Near-correct exp core: evaluate `exp(x)` (or `exp_m1(x)` when `minus_one`) at + /// `work_precision`, returning `(value, error_radius)`. + /// + /// Shared by the Ziv-backed `exp`/`exp_m1` (which retry it) and usable directly where only a + /// near-correct value is needed. The caller must have pre-checked that the reduction quotient + /// `s = floor(x/ln B)` fits `isize` (astronomical `|x|` overflows and is handled before the + /// Ziv loop, since this closure can't return `Err`). `n` (the reduction power, `≈ √p`) is + /// derived from the *target* precision and is constant across retries. + pub(crate) fn exp_compute( + &self, + x: &Repr, + work_precision: usize, + minus_one: bool, + n: usize, + mut cache: Option<&mut ConstCache>, + ) -> (FBig, FBig) { + // exp(x) = B^s · exp(r)^(Bⁿ), with r = x − s·ln(B) reduced so |r| < B⁻ⁿ. + let context = Context::::new(work_precision); + let x = FBig::new(context.repr_round_ref(x).value(), context); + + // When minus_one is true and |x| < 1/B, evaluate the Maclaurin series without scaling + // (no Bⁿ reduction, no powering — n_eff = 0). + let no_scaling = minus_one && x.log2_est() < -B.log2_est(); + + let (s, r, n_eff) = if no_scaling { + (0isize, x, 0usize) + } else { + let logb = context.ln_base::(reborrow_cache(&mut cache)); + let (s_big, r) = x.div_rem_euclid(logb); + let s: isize = s_big + .try_into() + .expect("exp reduction quotient fits isize (overflow pre-checked)"); + (s, r, n) + }; + let r = r >> n_eff as isize; + + // Maclaurin series: exp(r) = 1 + Σ rⁱ/i! + let mut factorial = IBig::ONE; + let mut pow = r.clone(); + let mut sum = if no_scaling { + r.clone() + } else { + FBig::ONE + &r + }; + let mut k = 2u32; + let mut terms: usize = 1; + loop { + factorial *= k; + pow *= &r; + + let increase = &pow / &factorial; + if increase.abs_cmp(&sum.sub_ulp()).is_le() { + break; + } + sum += increase; + k += 1; + terms += 1; + } + + // The radius is computed at *unlimited* precision so the bound arithmetic is exact — a + // work-precision product would drop digits and could under-estimate (a soundness hole). + let ulp_w = || sum.ulp().with_precision(0).value(); + + if no_scaling { + // exp_m1(x) = sum directly; error is the series truncation + rounding. + let radius = ulp_w() * (4 * terms + 8) + ulp_w(); + (sum, radius) + } else { + // Powering amplifies the series' relative error by Bⁿ. With |v|/|sum| < e < 3 (both + // near 1, since |r| < B⁻ⁿ), |v − true| ≤ 3·Bⁿ·(4K+8)·ulp(sum) + ulp(v). The B^s shift + // is exact, so the bound shifts with the value. + let pow_ctx = Context::::new(work_precision); + let v = pow_ctx.unwrap_fp(pow_ctx.powi(sum.repr(), Repr::::BASE.pow(n).into())); + let v_shifted = v.clone() << s; + let e_v = (ulp_w() << n as isize) * (4 * terms + 8) * 3u32 + + v.ulp().with_precision(0).value(); + let radius = if minus_one { + // result = v_shifted − 1; the subtraction adds one result-ULP of rounding. + let result = &v_shifted - FBig::ONE; + let radius = (e_v << s) + result.ulp().with_precision(0).value(); + return (result, radius); + } else { + e_v << s + }; + (v_shifted, radius) + } + } +} + +// `powf`/`exp`/`exp_m1` are correctly rounded (via the Ziv loop for exp/exp_m1, and via the +// Ziv-backed ln/exp primitives for powf), so they require `R: ErrorBounds`. +impl Context { /// Raise the floating point number to an floating point power under this context. /// /// Note that this method will not rely on [FBig::powi] even if the `exp` is actually an integer. @@ -353,101 +449,40 @@ impl Context { }; } - // A simple algorithm: - // - let r = (x - s logB) / Bⁿ, where s = floor(x / logB), such that r < B⁻ⁿ. - // - if the target precision is p digits, then there're only about p/m terms in Tyler series - // - finally, exp(x) = Bˢ * exp(r)^(Bⁿ) - // - the optimal n is √p as given by MPFR - - // Maclaurin series: exp(r) = 1 + Σ(rⁱ/i!) - // There will be about p/log_B(r) summations when calculating the series, to prevent - // loss of significance, we need about log_B(p) guard digits. - let series_guard_digits = ceil_usize(self.precision.log2_est() / B.log2_est()) + 2; - - // Reduction power: the series value is later raised to Bⁿ, which amplifies its - // relative error by a factor of Bⁿ. So the series (and the squarings) must carry - // about n extra base-B digits for the result to come out correct to p digits. We - // use 2n for safety — this mirrors MPFR's working precision q = precy + 2·K + … - // (K ≈ √precy is MPFR's squaring count, the analogue of our n). The log_B(p) - // summation/squaring rounding terms are already covered by series_guard_digits. - let n = 1usize << (self.precision.bit_len() / 2); - let pow_guard_digits = 2 * n; - let work_precision; - - // When minus_one is true and |x| < 1/B, the input is fed into the Maclaurin series without scaling - let no_scaling = minus_one && x.log2_est() < -B.log2_est(); - let (s, n, r) = if no_scaling { - // if minus_one is true and x is already small (x < 1/B), - // then directly evaluate the Maclaurin series without scaling - if x.sign() == Sign::Negative { - // extra digits are required to prevent cancellation during the summation - work_precision = self.precision + 2 * series_guard_digits; - } else { - work_precision = self.precision + series_guard_digits; - } - let context = Context::::new(work_precision); - (0, 0, FBig::new(context.repr_round_ref(x).value(), context)) - } else { - work_precision = self.precision + series_guard_digits + pow_guard_digits; - let context = Context::::new(work_precision); - let x = FBig::new(context.repr_round_ref(x).value(), context); - let logb = context.ln_base::(reborrow_cache(&mut cache)); - let (s, r) = x.div_rem_euclid(logb); - - let s: isize = match s.try_into() { - Ok(v) => v, - Err(_) => { - // |floor(x / ln B)| overflows isize — x is astronomically large, so the - // result is an infinity (x → +∞) or underflows to the limit (x → −∞). - return if input_sign == Sign::Positive { - Err(FpError::Overflow(Sign::Positive)) - } else if minus_one { - Ok(Exact(-FBig::ONE)) // exp_m1(−∞) = −1 (finite) - } else { - Err(FpError::Underflow(Sign::Positive)) // exp(−∞) = +0 - }; - } - }; - (s, n, r) - }; - - let r = r >> n as isize; - let mut factorial = IBig::ONE; - let mut pow = r.clone(); - let mut sum = if no_scaling { - r.clone() - } else { - FBig::ONE + &r - }; - - let mut k = 2; - loop { - factorial *= k; - pow *= &r; - - let increase = &pow / &factorial; - if increase.abs_cmp(&sum.sub_ulp()).is_le() { - break; + // Hoisted overflow check: the reduction quotient s = floor(x/ln B) overflows isize only + // for astronomically large |x| (|x| ≳ 2^61). The Ziv closure below can't return Err, so + // detect that case here and short-circuit to overflow/underflow (matching IEEE limits). + if x.log2_est().abs() > 61.0 { + let probe = Context::::new(self.precision + 64); + let logb = probe.ln_base::(reborrow_cache(&mut cache)); + let x_probe = FBig::new(probe.repr_round_ref(x).value(), probe); + let s_probe = x_probe.div_rem_euclid(logb).0; + if >::try_from(s_probe).is_err() { + return if input_sign == Sign::Positive { + Err(FpError::Overflow(Sign::Positive)) + } else if minus_one { + Ok(Exact(-FBig::ONE)) // exp_m1(−∞) = −1 (finite) + } else { + Err(FpError::Underflow(Sign::Positive)) // exp(−∞) = +0 + }; } - sum += increase; - k += 1; } - if no_scaling { - Ok(sum.with_precision(self.precision)) - } else if minus_one { - // Power at the series' working precision (it already carries the 2n guard - // digits that the Bⁿ powering amplifies away). The final "−1" can cancel at - // most ~1 leading digit here (the |x| < 1/B case is handled by no_scaling), - // which the same guard digits comfortably absorb. - let pow_ctx = Context::::new(work_precision); - let v = pow_ctx.unwrap_fp(pow_ctx.powi(sum.repr(), Repr::::BASE.pow(n).into())); - Ok(((v << s) - FBig::ONE).with_precision(self.precision)) - } else { - let pow_ctx = Context::::new(work_precision); - let v = pow_ctx.unwrap_fp(pow_ctx.powi(sum.repr(), Repr::::BASE.pow(n).into())); - Ok((v << s).with_precision(self.precision)) - } + // Correct rounding via the Ziv loop. Guards: log_B(p) for the series summation/squaring + // rounding, plus `n` for the Bⁿ powering amplification — halved from the pre-Ziv `2n`, + // since Ziv (not the guard count) now certifies correctness. `n ≈ √p` is derived from the + // target precision and is constant across retries. + let series_guard = ceil_usize(self.precision.log2_est() / B.log2_est()); + let n = 1usize << (self.precision.bit_len() / 2); + Ok(self.ziv(series_guard + n, |guard| { + self.exp_compute::( + x, + self.precision + guard, + minus_one, + n, + reborrow_cache(&mut cache), + ) + })) } } diff --git a/float/src/fbig_cached_ops.rs b/float/src/fbig_cached_ops.rs index c3f44e4c..20d6c423 100644 --- a/float/src/fbig_cached_ops.rs +++ b/float/src/fbig_cached_ops.rs @@ -12,7 +12,7 @@ use dashu_base::{Abs, CubicRoot, DivEuclid, DivRemEuclid, Inverse, RemEuclid, Si use crate::fbig::FBig; use crate::fbig_cached::CachedFBig; use crate::repr::{Context, Word}; -use crate::round::Round; +use crate::round::{ErrorBounds, Round}; // --------------------------------------------------------------------------- // CachedFBig op CachedFBig (preserves LHS cache) @@ -664,11 +664,6 @@ macro_rules! forward_to_fbig { } impl CachedFBig { - forward_to_context!(ln); - forward_to_context!(ln_1p); - forward_to_context!(exp); - forward_to_context!(exp_m1); - forward_to_fbig!(sqrt); forward_to_fbig!(inv); @@ -679,6 +674,41 @@ impl CachedFBig { forward_to_context_unwrap!(acos); forward_to_context_unwrap!(atan); + forward_to_fbig!(powi(exp: dashu_int::IBig)); + forward_to_fbig!(sqr); + forward_to_fbig!(cubic); + + /// Sine and cosine together (see [`FBig::sin_cos`]). + pub fn sin_cos(&self) -> (Self, Self) { + let mut guard = self.cache.borrow_mut(); + let cache = Some(&mut *guard); + let (s, c) = self.fbig.context.sin_cos::(&self.fbig.repr, cache); + ( + Self::from_fbig(self.fbig.context.unwrap_fp(s), &self.cache), + Self::from_fbig(self.fbig.context.unwrap_fp(c), &self.cache), + ) + } + + /// `atan2(y, x)` (see [`FBig::atan2`]). + pub fn atan2(&self, x: &Self) -> Self { + let mut c = self.cache.borrow_mut(); + let fbig = self.fbig.context.unwrap_fp(self.fbig.context.atan2::( + &self.fbig.repr, + &x.fbig.repr, + Some(&mut *c), + )); + Self::from_fbig(fbig, &self.cache) + } +} + +// Transcendentals that route through the Ziv-backed (or Ziv-dependent) Context methods require +// `R: ErrorBounds` for their correctness guarantee. +impl CachedFBig { + forward_to_context!(ln); + forward_to_context!(ln_1p); + forward_to_context!(exp); + forward_to_context!(exp_m1); + forward_to_context!(sinh); forward_to_context!(cosh); forward_to_context!(tanh); @@ -686,10 +716,6 @@ impl CachedFBig { forward_to_context!(acosh); forward_to_context!(atanh); - forward_to_fbig!(powi(exp: dashu_int::IBig)); - forward_to_fbig!(sqr); - forward_to_fbig!(cubic); - /// `self^exp` (see [`FBig::powf`]). pub fn powf(&self, exp: &Self) -> Self { let context = Context::max(self.fbig.context, exp.fbig.context); @@ -699,17 +725,6 @@ impl CachedFBig { Self::from_fbig(fbig, &self.cache) } - /// Sine and cosine together (see [`FBig::sin_cos`]). - pub fn sin_cos(&self) -> (Self, Self) { - let mut guard = self.cache.borrow_mut(); - let cache = Some(&mut *guard); - let (s, c) = self.fbig.context.sin_cos::(&self.fbig.repr, cache); - ( - Self::from_fbig(self.fbig.context.unwrap_fp(s), &self.cache), - Self::from_fbig(self.fbig.context.unwrap_fp(c), &self.cache), - ) - } - /// Hyperbolic sine and cosine together (see [`FBig::sinh_cosh`]). pub fn sinh_cosh(&self) -> (Self, Self) { let mut guard = self.cache.borrow_mut(); @@ -720,15 +735,4 @@ impl CachedFBig { Self::from_fbig(self.fbig.context.unwrap_fp(c), &self.cache), ) } - - /// `atan2(y, x)` (see [`FBig::atan2`]). - pub fn atan2(&self, x: &Self) -> Self { - let mut c = self.cache.borrow_mut(); - let fbig = self.fbig.context.unwrap_fp(self.fbig.context.atan2::( - &self.fbig.repr, - &x.fbig.repr, - Some(&mut *c), - )); - Self::from_fbig(fbig, &self.cache) - } } diff --git a/float/src/lib.rs b/float/src/lib.rs index b840457d..e03d79cd 100644 --- a/float/src/lib.rs +++ b/float/src/lib.rs @@ -94,6 +94,7 @@ mod shift; mod sign; mod third_party; mod utils; +mod ziv; // All the public items from third_party will be exposed #[allow(unused_imports)] diff --git a/float/src/log.rs b/float/src/log.rs index 02772914..042e2edc 100644 --- a/float/src/log.rs +++ b/float/src/log.rs @@ -11,7 +11,7 @@ use crate::{ fbig::FBig, math::cache::{reborrow_cache, ConstCache}, repr::{Context, Repr, Word}, - round::{Round, Rounded}, + round::{ErrorBounds, Round, Rounded}, utils::ceil_usize, }; use core::cmp::Ordering; @@ -63,7 +63,7 @@ impl EstimatedLog2 for FBig { } } -impl FBig { +impl FBig { /// Calculate the natural logarithm function (`log(x)`) on the float number. /// /// # Examples @@ -99,6 +99,12 @@ impl FBig { } } +// `ln2`/`ln10`/`iacoth`/`ln_base`/`ln_compute` are the near-correct logarithm primitives: they +// evaluate the series at a working precision and round once, without a Ziv certification step. +// They live on `R: Round` so that base conversion (`with_base_and_precision`, which only needs a +// near-correct constant `ln(B)`) can use them without inheriting the `ErrorBounds` bound. The +// correctly-rounded public `ln`/`ln_1p` (in the `ErrorBounds` impl below) wrap `ln_compute` in a +// Ziv loop. impl Context { /// Calculate log(2) /// @@ -138,7 +144,19 @@ impl Context { 2 => self.ln2(None), 10 => self.ln10(None), i if i.is_power_of_two() => self.ln2(None) * i.trailing_zeros(), - _ => self.unwrap_fp(self.ln(&Repr::new(Repr::::BASE.into(), 0), None)), + _ => { + // Near-correct ln(B) via the atanh series (no Ziv certification — base conversion + // only needs a near-correct constant). `ln_compute` is on `R: Round`, so this keeps + // `ln_base` callable from `R: Round` contexts (base conversion). + let guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 2; + self.ln_compute::( + &Repr::new(Repr::::BASE.into(), 0), + self.precision + guard, + false, + None, + ) + .0 + } } } @@ -179,6 +197,102 @@ impl Context { num / denom } + /// Evaluate `ln(x)` (or `ln(x+1)` when `one_plus`) at `work_precision` via the atanh series, + /// returning `(value, error_radius)`. + /// + /// This is the near-correct computation core shared by the public Ziv-backed `ln`/`ln_1p` + /// (which wrap it in a retry loop) and by `ln_base` (which only needs a near-correct constant + /// `ln(B)`). It lives on `R: Round` so those near-correct callers don't inherit the + /// `ErrorBounds` bound. The radius is a provable upper bound on `|value − true|`, derived from + /// the term count (every series step is correctly rounded; the truncated tail is `< 1 ulp` by + /// the break test). + pub(crate) fn ln_compute( + &self, + x: &Repr, + mut work_precision: usize, + one_plus: bool, + mut cache: Option<&mut ConstCache>, + ) -> (FBig, FBig) { + // log(x) = log(x·B⁻ˢ) + s·log(B), with s = floor(log_B(x)) so x·B⁻ˢ ∈ [1, B). + let context = Context::::new(work_precision); + let x = FBig::new(context.repr_round_ref(x).value(), context); + + // When one_plus is true and |x| < 1/B, the input is fed into the Maclaurin without scaling + let no_scaling = one_plus && x.log2_est() < -B.log2_est(); + + let (s, mut x_scaled) = if no_scaling { + (0, x) + } else { + let x = if one_plus { x + FBig::ONE } else { x }; + + let log2 = x.log2_bounds().0; + let s = log2 as isize - (log2 < 0.) as isize; // floor(log2(x)) + + let x_scaled = if B == 2 { + x >> s + } else if s > 0 { + x / (IBig::ONE << s as usize) + } else { + x * (IBig::ONE << (-s) as usize) + }; + debug_assert!(x_scaled >= FBig::::ONE); + (s, x_scaled) + }; + + if s < 0 || x_scaled.repr.sign() == Sign::Negative { + // when s or x_scaled is negative, the final addition is actually a subtraction, + // therefore we need to double the precision to get the correct result + work_precision += self.precision; + x_scaled.context.precision = work_precision; + } + let work_context = Context::new(work_precision); + + // after the number is scaled to nearly one, use Maclaurin series on log(x) = 2atanh(z): + // let z = (x-1)/(x+1) < 1, log(x) = 2atanh(z) = 2Σ(z²ⁱ⁺¹/(2i+1)) for i = 1,3,5,... + let z = if no_scaling { + let d = &x_scaled + (FBig::ONE + FBig::ONE); + x_scaled / d + } else { + (&x_scaled - FBig::ONE) / (x_scaled + FBig::ONE) + }; + let z2 = z.sqr(); + let mut pow = z.clone(); + let mut sum = z; + let mut terms: usize = 1; // the leading z term + + let mut k: usize = 3; + loop { + pow *= &z2; + + let increase = &pow / work_context.convert_int::(k.into()).value(); + if increase.abs_cmp(&sum.sub_ulp()).is_le() { + break; + } + + sum += increase; + k += 2; + terms += 1; + } + + // compose the logarithm of the original number + let result: FBig = if no_scaling { + 2 * sum.clone() + } else { + 2 * sum.clone() + (s * work_context.ln2::(reborrow_cache(&mut cache))) + }; + + // Provable error radius: each series step is correctly rounded (< 1 working-ULP) and the + // truncated tail is < 1 working-ULP by the break test; `pow *= z²` compounds but |z|<1 + // keeps each term's error bounded, so |sum − true| < (4·terms + 8)·ulp(sum). The factor-2 + // reconstruction and the s·ln2 term add a few result-ULPs on top. + let radius = sum.ulp() * (8 * terms + 16) + result.ulp() + result.ulp(); + (result, radius) + } +} + +// `ln`/`ln_1p` are correctly rounded via the Ziv loop, whose containment test needs the rounding +// preimage (`R: ErrorBounds`). They delegate the series to `ln_compute`. +impl Context { /// Calculate the natural logarithm function (`log(x)`) on the float number under this context. /// /// # Examples @@ -272,77 +386,16 @@ impl Context { return Exact(zero); } - // A simple algorithm: - // - let log(x) = log(x/2^s) + slog2 where s = floor(log2(x)) - // - such that x*2^s is close to but larger than 1 (and x*2^s < 2) - let guard_digits = ceil_usize(self.precision.log2_est() / B.log2_est()) + 2; - let mut work_precision = self.precision + guard_digits + one_plus as usize; - let context = Context::::new(work_precision); - let x = FBig::new(context.repr_round_ref(x).value(), context); - - // When one_plus is true and |x| < 1/B, the input is fed into the Maclaurin without scaling - let no_scaling = one_plus && x.log2_est() < -B.log2_est(); - - let (s, mut x_scaled) = if no_scaling { - (0, x) - } else { - let x = if one_plus { x + FBig::ONE } else { x }; - - let log2 = x.log2_bounds().0; - let s = log2 as isize - (log2 < 0.) as isize; // floor(log2(x)) - - let x_scaled = if B == 2 { - x >> s - } else if s > 0 { - x / (IBig::ONE << s as usize) - } else { - x * (IBig::ONE << (-s) as usize) - }; - debug_assert!(x_scaled >= FBig::::ONE); - (s, x_scaled) - }; - - if s < 0 || x_scaled.repr.sign() == Sign::Negative { - // when s or x_scaled is negative, the final addition is actually a subtraction, - // therefore we need to double the precision to get the correct result - work_precision += self.precision; - x_scaled.context.precision = work_precision; - }; - let work_context = Context::new(work_precision); - - // after the number is scaled to nearly one, use Maclaurin series on log(x) = 2atanh(z): - // let z = (x-1)/(x+1) < 1, log(x) = 2atanh(z) = 2Σ(z²ⁱ⁺¹/(2i+1)) for i = 1,3,5,... - // similar to iacoth, the required iterations stop at i = -p/2log_B(z), and we need log_B(i) guard bits - let z = if no_scaling { - let d = &x_scaled + (FBig::ONE + FBig::ONE); - x_scaled / d - } else { - (&x_scaled - FBig::ONE) / (x_scaled + FBig::ONE) - }; - let z2 = z.sqr(); - let mut pow = z.clone(); - let mut sum = z; - - let mut k: usize = 3; - loop { - pow *= &z2; - - let increase = &pow / work_context.convert_int::(k.into()).value(); - if increase.abs_cmp(&sum.sub_ulp()).is_le() { - break; - } - - sum += increase; - k += 2; - } - - // compose the logarithm of the original number - let result: FBig = if no_scaling { - 2 * sum - } else { - 2 * sum + s * work_context.ln2::(reborrow_cache(&mut cache)) - }; - result.with_precision(self.precision) + // Correct rounding via the Ziv loop: `ln_compute` evaluates the atanh series at `p + guard` + // and reports a provable error radius; the driver retries with more guard digits until the + // approximation's error interval lies entirely inside one rounding bin. The guard is a + // *performance* knob (first-attempt hit rate), not a correctness backstop — Ziv certifies + // the result. (The pre-Ziv `+ 2` is retained: with the conservative radius below it is still + // needed for the first attempt to clear the half-ulp preimage at typical precisions.) + let base_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 2; + self.ziv(base_guard + one_plus as usize, |guard| { + self.ln_compute::(x, self.precision + guard, one_plus, reborrow_cache(&mut cache)) + }) } } diff --git a/float/src/math/cache.rs b/float/src/math/cache.rs index 158d00c4..0757a4aa 100644 --- a/float/src/math/cache.rs +++ b/float/src/math/cache.rs @@ -213,14 +213,18 @@ impl ConstCache { .value() } _ => { - // generic base: no cached L(n) sub-series applies, so compute - // ln(B) directly through Context::ln on the base literal. + // generic base: no cached L(n) sub-series applies, so compute ln(B) directly via + // the near-correct `ln_compute` (the cache only stores a near-correct constant — a + // cached value is re-rounded on read, and Ziv isn't needed here). let ctx = Context::::new(precision); - ctx.unwrap_fp(ctx.ln::( + ctx.ln_compute::( &Repr::new(Repr::::BASE.into(), 0), // no cache for the generic base (its L(n) isn't cached) + precision, + false, None, - )) + ) + .0 } } } diff --git a/float/src/math/hyper.rs b/float/src/math/hyper.rs index 85f3a9c7..091009ee 100644 --- a/float/src/math/hyper.rs +++ b/float/src/math/hyper.rs @@ -20,11 +20,11 @@ use crate::{ FpResult, }, repr::{Context, Repr, Word}, - round::Round, + round::{ErrorBounds, Round}, }; use dashu_base::{Abs, AbsOrd, Approximation::Exact, Sign}; -impl Context { +impl Context { /// Hyperbolic sine. pub fn sinh( &self, @@ -287,7 +287,7 @@ impl Context { } } -impl FBig { +impl FBig { /// Calculate the hyperbolic sine of the floating point number. /// /// # Examples diff --git a/float/src/third_party/num_traits.rs b/float/src/third_party/num_traits.rs index 5368fc7a..96c5ffa6 100644 --- a/float/src/third_party/num_traits.rs +++ b/float/src/third_party/num_traits.rs @@ -1,6 +1,9 @@ //! Implement num-traits traits. -use crate::{fbig::FBig, round::Round}; +use crate::{ + fbig::FBig, + round::{ErrorBounds, Round}, +}; use dashu_base::{Abs, DivEuclid, ParseError, RemEuclid, Sign}; use dashu_int::{IBig, Word}; use num_traits_v02 as num_traits; @@ -114,14 +117,14 @@ impl num_traits::Pow for &FBig { self.powi(rhs) } } -impl num_traits::Pow<&FBig> for FBig { +impl num_traits::Pow<&FBig> for FBig { type Output = FBig; fn pow(self, rhs: &Self) -> Self { self.powf(rhs) } } -impl num_traits::Pow<&FBig> for &FBig { +impl num_traits::Pow<&FBig> for &FBig { type Output = FBig; fn pow(self, rhs: &FBig) -> FBig { diff --git a/float/src/ziv.rs b/float/src/ziv.rs new file mode 100644 index 00000000..240a1d2c --- /dev/null +++ b/float/src/ziv.rs @@ -0,0 +1,191 @@ +//! The Ziv retry loop — guaranteed-correct rounding for transcendentals. +//! +//! Transcendentals (`exp`, `ln`, …) cannot compute an exact result the way arithmetic can, +//! so they approximate at a working precision `p + guard` and round down. A single guard-digit +//! heuristic is only *near*-correct: for an input whose true value sits on a rounding boundary, +//! the rounded result can be off by one ULP. The Ziv loop closes that gap. +//! +//! Each transcendental reports its approximation together with a provable absolute error radius +//! `E` (a true upper bound on `|approx − true|`, built from the fact that every `+`/`-`/`*`/`/` +//! in the algorithm is itself correctly rounded). The driver rounds the approximation to the +//! target precision and checks whether the entire error interval `[ã − E, ã + E]` lies inside +//! the candidate's [`ErrorBounds`] preimage — the set of reals that round to it. If it does, +//! the rounded value is *guaranteed* correct; otherwise the driver retries with more guard +//! digits. The loop provably terminates (a true tie is resolved deterministically by the mode), +//! with a large sanity cap as an unreachable backstop. + +use dashu_base::Approximation::*; + +use crate::{fbig::FBig, repr::Context, round::ErrorBounds, round::Rounded}; +use dashu_int::Word; + +/// Maximum number of Ziv retries before falling back to the best-effort rounded value. +/// +/// This is a sanity backstop only — the loop converges as soon as the working precision is +/// large enough that the error interval no longer straddles a rounding boundary, which happens +/// in one attempt for essentially all inputs (the guard-digit heuristic is sized for that) and +/// in a handful of attempts only for inputs pathological close to a tie. The cap exists so a +/// bug in an error-radius bound can never produce an infinite loop. +const MAX_ZIV_RETRIES: usize = 32; + +// A test-only retry counter, so tests can assert the loop converges on the first attempt for +// typical inputs (validating that the guard-digit heuristic wasn't over-tightened). Reads as +// the number of *extra* attempts beyond the first, i.e. `0` means first-attempt success. +#[cfg(test)] +thread_local! { + pub(crate) static LAST_ZIV_RETRIES: core::cell::Cell = const { core::cell::Cell::new(0) }; +} + +impl Context { + /// Correctly round a transcendental approximation to this context's precision using a Ziv + /// retry loop. + /// + /// `approx(guard)` computes the function at working precision `self.precision + guard` and + /// returns `(value, error_radius)` — the value and a provable upper bound on its absolute + /// error, both as [`FBig`]s at the working context. The closure is expected to capture and + /// reborrow any [`ConstCache`](crate::ConstCache) from the enclosing scope; the driver calls + /// it once per attempt and grows `guard` when the result cannot be certified. + /// + /// The loop preserves the [`Exact`](Rounded)/[`Inexact`](Rounded) flag from rounding the + /// approximation to the target precision. + pub(crate) fn ziv( + &self, + initial_guard: usize, + mut approx: impl FnMut(usize) -> (FBig, FBig), + ) -> Rounded> { + // Unlimited precision: the approximation is exact, so report it as-is. + if !self.is_limited() { + let (value, _err) = approx(0); + return Exact(value); + } + + let mut guard = initial_guard; + let mut last = None; + #[cfg(test)] + LAST_ZIV_RETRIES.with(|c| c.set(0)); + for _ in 0..MAX_ZIV_RETRIES { + let (a, e) = approx(guard); + // `with_precision` consumes `a`, but the containment test still needs it, so round a + // clone and keep the original for the interval check. + let candidate = a.clone().with_precision(self.precision); + if Self::contained::(&a, &e, candidate.value_ref()) { + return candidate; + } + last = Some(candidate); + + // Grow the guard aggressively so a near-tie resolves in a couple of retries, while + // the first attempt (with the heuristic guard) handles the common case. + let step = core::cmp::max(guard, self.precision / 2).max(1); + guard += step; + #[cfg(test)] + LAST_ZIV_RETRIES.with(|c| c.set(c.get() + 1)); + } + + // Unreachable in practice: return the best-effort candidate from the last attempt, + // matching the pre-Ziv near-correct behavior rather than looping forever. + last.expect("MAX_ZIV_RETRIES is non-zero") + } + + /// Containment test: is the approximation's error interval `[a − e, a + e]` entirely inside + /// the rounding preimage of `y` (every real in `[y − L, y + R]` rounds to `y` under `R`)? + /// + /// The arithmetic is done at unlimited precision (an exact no-op promotion via + /// [`FBig::with_precision`](crate::FBig)`(0)`), so the comparison cannot lose a guard digit + /// and mis-decide — a soundness requirement, since a wrong decision here yields a wrong ULP. + fn contained(a: &FBig, e: &FBig, y: &FBig) -> bool { + let (lb, rb, incl_l, incl_r) = R::error_bounds::(y); + + // Promote to unlimited precision so the interval arithmetic is exact. + let a = a.clone().with_precision(0).value(); + let e = e.clone().with_precision(0).value(); + let y = y.clone().with_precision(0).value(); + let lb = lb.with_precision(0).value(); + let rb = rb.with_precision(0).value(); + + // [a − e, a + e] ⊆ [y − lb, y + rb], respecting each endpoint's inclusivity. + let lo = &a - &e; // a − e + let hi = &a + &e; // a + e + let pre_lo = &y - &lb; // y − L + let pre_hi = &y + &rb; // y + R + let left_ok = if incl_l { lo >= pre_lo } else { lo > pre_lo }; + let right_ok = if incl_r { hi <= pre_hi } else { hi < pre_hi }; + left_ok && right_ok + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::round::mode; + + type F = crate::FBig; + + // An exact approximation (radius 0) is accepted on the first attempt as Exact. + #[test] + fn ziv_accepts_exact_first_attempt() { + let ctx: Context = Context::new(10); + LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX)); + let r = ctx.ziv(4, |_| (F::ONE, F::ZERO)); + assert!(matches!(r, Exact(_))); + assert_eq!(LAST_ZIV_RETRIES.with(|c| c.get()), 0); + } + + // An approximation whose error interval straddles a rounding boundary must retry; once the + // shrinking radius makes the interval unambiguous, ziv accepts. + #[test] + fn ziv_retries_until_contained() { + let ctx: Context = Context::new(4); + let r = ctx.ziv(2, |guard| { + // value 1.0, radius 2^(-guard): large on the first attempt, tiny later. + (F::ONE, F::ONE >> guard as isize) + }); + let _ = r.value(); + assert!(LAST_ZIV_RETRIES.with(|c| c.get()) >= 1); + } + + // Unlimited-precision context short-circuits to a single Exact call (counter untouched). + #[test] + fn ziv_unlimited_short_circuits() { + let ctx: Context = Context::new(0); + LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX)); + let r = ctx.ziv(4, |_| (F::from(7u8), F::ZERO)); + assert!(matches!(r, Exact(_))); + assert_eq!(LAST_ZIV_RETRIES.with(|c| c.get()), usize::MAX); + } + + // The guard-digit heuristic should let exp/ln converge in at most one retry for typical + // inputs. A single retry on a near-tie is by design (Ziv certifies correctness; the guard only + // controls the first-attempt hit rate). This catches gross guard mis-sizing (many retries), + // not the occasional near-tie retry. + #[test] + fn ziv_few_retries_for_typical_inputs() { + let cases = [ + F::try_from(0.5f64).unwrap(), + F::try_from(1.5f64).unwrap(), + F::try_from(2.0f64).unwrap(), + F::try_from(10.0f64).unwrap(), + F::try_from(1000.0f64).unwrap(), + F::try_from(1e-6f64).unwrap(), + ]; + const MAX_RETRIES: usize = 1; + for p in [10usize, 24, 53, 100, 200] { + for x in &cases { + let x = x.clone().with_precision(p).value(); + LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX)); + let _ = x.ln(); + let ln_retries = LAST_ZIV_RETRIES.with(|c| c.get()); + assert!( + ln_retries <= MAX_RETRIES, + "ln({x}) at p={p} took {ln_retries} retries (expected <= {MAX_RETRIES})" + ); + LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX)); + let _ = x.exp(); + let exp_retries = LAST_ZIV_RETRIES.with(|c| c.get()); + assert!( + exp_retries <= MAX_RETRIES, + "exp({x}) at p={p} took {exp_retries} retries (expected <= {MAX_RETRIES})" + ); + } + } + } +} diff --git a/float/tests/exp.rs b/float/tests/exp.rs index 056d20c8..2359ef59 100644 --- a/float/tests/exp.rs +++ b/float/tests/exp.rs @@ -447,9 +447,9 @@ fn test_pow_inf() { #[test] fn test_pow_with_rounding() { use dashu_base::Abs; - use dashu_float::round::{mode::*, Round}; + use dashu_float::round::{mode::*, ErrorBounds, Round}; - fn test_powf_with_error( + fn test_powf_with_error( base: &FBig, exp: &FBig, target: &FBig, diff --git a/float/tests/exp_log_root_prop.rs b/float/tests/exp_log_root_prop.rs index b2f918aa..6cc32ed4 100644 --- a/float/tests/exp_log_root_prop.rs +++ b/float/tests/exp_log_root_prop.rs @@ -85,14 +85,42 @@ proptest! { prop_assert!(lower < x && x < upper); } - /// Correct-rounding self-oracle for ln: the p-precision result agrees with the - /// (rounded-down) 2p-precision result to within one ulp. + /// Correct-rounding self-oracle for ln: the p-precision result must agree *exactly* with the + /// 2p-precision result re-rounded to p (the Ziv loop guarantees both are the unique + /// correctly-rounded value). Pre-Ziv this only held to within one ulp. #[test] - fn ln_rounding_self_oracle(x in pos_x(100_000)) { + fn ln_rounding_exact_oracle(x in pos_x(100_000)) { let r_p = x.ln(); let x_2p = x.clone().with_precision(2 * P).value(); let r_2p = x_2p.ln().with_precision(P).value(); - let ulp = r_p.ulp(); - prop_assert!((r_p - r_2p).abs() <= ulp); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for exp: same construction as `ln_rounding_exact_oracle`. + #[test] + fn exp_rounding_exact_oracle(x in signed_x(2_000)) { + let r_p = x.exp(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.exp().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for exp_m1. + #[test] + fn exp_m1_rounding_exact_oracle(x in signed_x(2_000)) { + let r_p = x.exp_m1(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.exp_m1().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for ln_1p (domain x > -1; m·10⁻⁴ with m > -10000 keeps x > -1). + #[test] + fn ln_1p_rounding_exact_oracle(m in -9999i64..100_000i64) { + let x = x_from(m); + let r_p = x.ln_1p(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.ln_1p().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); } } diff --git a/float/tests/log.rs b/float/tests/log.rs index 05aac914..2a8c3012 100644 --- a/float/tests/log.rs +++ b/float/tests/log.rs @@ -202,9 +202,9 @@ fn test_ln_1p_inf() { #[test] fn test_ln_with_rounding() { use dashu_base::Abs; - use dashu_float::round::{mode::*, Round}; + use dashu_float::round::{mode::*, ErrorBounds, Round}; - fn test_ln_with_error( + fn test_ln_with_error( base: &FBig, target: &FBig, atol: &FBig, diff --git a/guide-zh/src/compliance.md b/guide-zh/src/compliance.md index b6582be1..f5e11a34 100644 --- a/guide-zh/src/compliance.md +++ b/guide-zh/src/compliance.md @@ -51,7 +51,7 @@ | IEEE 754 要求 | 合规性 | 说明 | |---------------------|-----------|-------| | 舍入模式:roundTiesToEven、roundTiesToAway、roundTowardPositive、roundTowardNegative、roundTowardZero | ✅ | 全部五种模式实现为 `HalfEven`、`HalfAway`、`Up`、`Down`、`Zero`。 | -| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。`Rounded` 类型区分精确与非精确结果。 | +| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及基本超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`)通过 Ziv 循环保证正确舍入;其余超越函数通过保护位策略在 1 ulp 以内。`Rounded` 类型区分精确与非精确结果。 | | 就近舍入保持零的符号 | ✅ | `rounded_to_repr` 在舍入将非零值折叠为零时保持输入符号。 | #### 第 5.6 节——符号位运算 @@ -143,7 +143,7 @@ |---------------------|-----------|-------| | 无效/不定形式 → NaN | ❌ 偏离 | 在 `Context` 层返回 `Err(FpError::{Indeterminate, InfiniteInput})`;在便捷层 panic。设计上无 NaN。 | | 定义域错误(如负数的偶数根、超出范围的逆三角函数) | ❌ 偏离 | 返回 `Err(FpError::OutOfDomain)` / panic,而非 NaN 结果。 | -| 每个分量独立按共享模式舍入 | ✅ | 每个轴近似正确舍入(保证正确的 Ziv 循环推迟到 0.5.x)。 | +| 每个分量独立按共享模式舍入 | ✅ | 每个轴近似正确舍入(基本实超越函数的保证正确 Ziv 循环已落入 `dashu-float`;复数超越函数仍使用保护位策略,在 1 ulp 以内)。 | ### 小结(dashu-cmplx) diff --git a/guide-zh/src/faq.md b/guide-zh/src/faq.md index 3823e9f6..1c620921 100644 --- a/guide-zh/src/faq.md +++ b/guide-zh/src/faq.md @@ -26,7 +26,7 @@ ## 已知限制 - **没有 NaN。** 无效运算在便捷层会 panic,在上下文层返回 `Err(FpError)`。无穷是终态值,不是操作数——参见[标准合规性](./compliance.md)。 -- **近似正确舍入。** 超越函数通过保护位策略在 1 ulp 以内舍入;保证正确的 Ziv 循环计划在后续版本中实现。 +- **近似正确舍入。** 基本超越函数 `exp`、`exp_m1`、`ln`、`ln_1p` 通过 Ziv 重试循环保证正确舍入。其余超越函数(三角、双曲、`powf`、复数)仍通过保护位策略在 1 ulp 以内舍入。 - **复数功能覆盖。** `CBig` 提供域运算和基本超越函数;复数双曲函数、`fma` 及其他若干功能推迟到 0.5.x(参见 v0.5 版本说明)。 - **没有球体/区间运算。** 与 MPC 的实验性 `mpcb_t`(复数球体)不同,`dashu` 不提供区间或球体类型。这是一个刻意的范围选择:球体运算在上游仍是实验性的,如果/当它稳定后,最好由一个**独立的 crate** 在 `dashu-float`/`dashu-cmplx` 之上提供,而非耦合到核心类型。 - **尚无 SIMD-FFT 乘法**(计划在 v1.0 中实现)。 diff --git a/guide/src/compliance.md b/guide/src/compliance.md index dde61f8a..1c1474ee 100644 --- a/guide/src/compliance.md +++ b/guide/src/compliance.md @@ -56,7 +56,7 @@ The reference here is IEEE Std 754™-2008 (ISO/IEC/IEEE 60559:2011). | IEEE 754 requirement | Compliance | Notes | |---------------------|-----------|-------| | Rounding modes: roundTiesToEven, roundTiesToAway, roundTowardPositive, roundTowardNegative, roundTowardZero | ✅ | All five modes implemented as `HalfEven`, `HalfAway`, `Up`, `Down`, `Zero`. | -| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. The `Rounded` type distinguishes exact from inexact results. | +| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the elementary transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`) are guaranteed-correctly rounded via a Ziv loop; the remaining transcendentals are within 1 ulp via a guard-digit recipe. The `Rounded` type distinguishes exact from inexact results. | | Round-to-nearest preserves sign of zero | ✅ | `rounded_to_repr` preserves input sign when rounding collapses a non-zero to zero. | #### Section 5.6 — Sign bit operations @@ -152,7 +152,7 @@ the `Context` layer (and panics at the convenience layer). |---------------------|-----------|-------| | Invalid / indeterminate form → NaN | ❌ Deviates | `Err(FpError::{Indeterminate, InfiniteInput})` at `Context`; panics at the convenience layer. No NaN by design. | | Domain error (e.g. even root of a negative value, out-of-range inverse trig) | ❌ Deviates | `Err(FpError::OutOfDomain)` / panic, rather than a NaN result. | -| Each component rounded independently to the shared mode | ✅ | Near-correctly rounded per axis (a guaranteed-correct Ziv loop is deferred to 0.5.x). | +| Each component rounded independently to the shared mode | ✅ | Near-correctly rounded per axis (a guaranteed-correct Ziv loop for the elementary real transcendentals has landed in `dashu-float`; the complex transcendentals still use a guard-digit recipe, within 1 ulp). | ### Summary (dashu-cmplx) diff --git a/guide/src/faq.md b/guide/src/faq.md index 22e8028f..0cd898a3 100644 --- a/guide/src/faq.md +++ b/guide/src/faq.md @@ -29,7 +29,7 @@ Compared with other Rust crates: ## Known limitations - **No NaN.** Invalid operations panic at the convenience layer and return `Err(FpError)` at the context layer. Infinities are terminal values, not operands — see [Standards Compliance](./compliance.md). -- **Near-correct rounding.** Transcendentals are rounded within 1 ulp via a guard-digit recipe; a guaranteed-correct Ziv loop is planned for a later release. +- **Near-correct rounding.** The elementary transcendentals `exp`, `exp_m1`, `ln`, `ln_1p` are guaranteed-correctly rounded via a Ziv retry loop. The remaining transcendentals (trig, hyperbolic, `powf`, complex) are still rounded within 1 ulp via a guard-digit recipe. - **Complex surface.** `CBig` ships field arithmetic and the elementary transcendentals; complex hyperbolics, `fma`, and several others are deferred to 0.5.x (see the v0.5 release notes). - **No ball/interval arithmetic.** Unlike MPC's experimental `mpcb_t` (complex balls), `dashu` does not provide interval or ball types. This is a deliberate scope choice: ball arithmetic is From 4ee645178cc5703679804369f3c3abe8a966e9fd Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Sun, 19 Jul 2026 15:48:12 +0800 Subject: [PATCH 02/21] Fix log perf --- float/src/log.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/float/src/log.rs b/float/src/log.rs index 042e2edc..c6ca3d49 100644 --- a/float/src/log.rs +++ b/float/src/log.rs @@ -281,11 +281,16 @@ impl Context { 2 * sum.clone() + (s * work_context.ln2::(reborrow_cache(&mut cache))) }; - // Provable error radius: each series step is correctly rounded (< 1 working-ULP) and the - // truncated tail is < 1 working-ULP by the break test; `pow *= z²` compounds but |z|<1 - // keeps each term's error bounded, so |sum − true| < (4·terms + 8)·ulp(sum). The factor-2 - // reconstruction and the s·ln2 term add a few result-ULPs on top. - let radius = sum.ulp() * (8 * terms + 16) + result.ulp() + result.ulp(); + // Provable error radius, expressed in `result`-ULPs (not `sum`-ULPs). Each series step + // rounds once (< 1 ULP of the running sum) and the truncated tail is < 1 ULP by the break + // test, so |sum − true| < (terms + 2)·ulp(sum); result = 2·sum + s·ln2 amplifies by ~2 and + // adds a few reconstruction ULPs. Since result ≈ 2·sum, ulp(result) ≈ 2·ulp(sum), giving + // |result − true| < (terms + 2)·ulp(result) + overhead — we carry a generous margin. + // + // Basing the radius on `result.ulp()` (not `sum.ulp()`) keeps its exponent aligned with + // `a` (= result) in the Ziv containment test, so `a − e` avoids a slow exponent-misaligned + // unlimited-precision subtract — a ~3× speedup on `ln` at high precision. + let radius = result.ulp() * (4 * terms + 12); (result, radius) } } From 4022dc051bb204bd806f9413074bcb63ff74b016 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Sun, 19 Jul 2026 17:41:07 +0800 Subject: [PATCH 03/21] Simplify basic operations for Repr --- float/CHANGELOG.md | 6 + float/src/iter.rs | 45 ++---- float/src/lib.rs | 1 + float/src/mul.rs | 79 ++++------ float/src/repr.rs | 20 --- float/src/repr_ops.rs | 324 ++++++++++++++++++++++++++++++++++++++++++ float/src/sign.rs | 8 -- float/src/ziv.rs | 60 +++++--- 8 files changed, 405 insertions(+), 138 deletions(-) create mode 100644 float/src/repr_ops.rs diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index ee15b0e7..c492fb67 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -6,6 +6,12 @@ - `Repr::new_const`: a `const`-evaluable, normalized `Repr` constructor from a `DoubleWord` significand (the `const` counterpart of `Repr::new`). `FBig::from_parts_const` now delegates to it, and the complex literal macro uses it. +- **Exact `Add`/`Sub`/`Mul` operators for `Repr`** (in a new `repr_ops` module). A `Repr` carries no + precision limit, so add/sub/mul on it are lossless — these are the shared primitives the crate uses + for exact intermediates (the Ziv containment test, the correctly-rounded `Sum`, and the `FBig` + multiply path). `Mul` saturates exponent overflow/underflow to the signed infinity/zero sentinels, + so the operator is infallible; the internal `make_mul_repr`/`unwrap_mul_repr!` helpers are removed + in favor of the operator. No behavior change to `FBig` arithmetic. - **Guaranteed-correct rounding for `exp`, `exp_m1`, `ln`, `ln_1p`** via a Ziv retry loop. A generic `Context::ziv` driver rounds the working-precision approximation to the target precision and verifies, against the `ErrorBounds` rounding preimage, that the approximation's provable error diff --git a/float/src/iter.rs b/float/src/iter.rs index 9e5701ad..561b8e70 100644 --- a/float/src/iter.rs +++ b/float/src/iter.rs @@ -5,38 +5,9 @@ use crate::{ fbig::FBig, repr::{Context, Repr, Word}, round::Round, - utils::{shl_digits, shl_digits_in_place}, }; use core::iter::{Product, Sum}; -/// Exact (unrounded) sum of two finite [`Repr`] values. -/// -/// The result exponent is `min(lhs.exponent, rhs.exponent)`. The larger-exponent operand's -/// significand is shifted up by the exponent gap (in base-`B` digits), then the signed significands -/// are added — `IBig` addition handles opposite signs as an exact subtraction. Zero operands short -/// circuit so that a `-0` sentinel exponent (`-1`) can't bleed into the result exponent. The result -/// is rebuilt with [`Repr::new`], which strips trailing zeros. -fn exact_add(mut lhs: Repr, rhs: &Repr) -> Repr { - if lhs.significand.is_zero() { - return rhs.clone(); - } - if rhs.significand.is_zero() { - return lhs; - } - - if lhs.exponent >= rhs.exponent { - let ediff = (lhs.exponent - rhs.exponent) as usize; - shl_digits_in_place::(&mut lhs.significand, ediff); - lhs.significand += &rhs.significand; - lhs.exponent = rhs.exponent; - } else { - let ediff = (rhs.exponent - lhs.exponent) as usize; - let rhs_sig = shl_digits::(&rhs.significand, ediff); - lhs.significand += rhs_sig; // lhs.exponent is already the minimum - } - Repr::new(lhs.significand, lhs.exponent) -} - /// Correctly-rounded summation of finite floats. /// /// Every addend is accumulated exactly at the [`Repr`] level (no per-step rounding); the exact total @@ -58,7 +29,7 @@ fn precise_sum( }; for (repr, ctx) in iter { assert_finite(&repr); - acc = exact_add(acc, &repr); + acc = acc + &repr; context = Context::max(context, ctx); } // Exact cancellation can leave `acc` with a zero significand whose exponent coincides with the @@ -223,17 +194,17 @@ mod tests { let _: F = core::iter::once(F::INFINITY).sum(); } - // Exercises the zero short-circuit in `exact_add`: a `-0` sentinel exponent must not corrupt the - // accumulator exponent. + // Exercises the zero short-circuit in `impl Add for &Repr`: a `-0` sentinel exponent must not + // corrupt the accumulator exponent. #[test] - fn exact_add_neg_zero_is_identity() { + fn repr_add_neg_zero_is_identity() { let nz = Repr::<10>::neg_zero(); let x = r::<10>(5, -2); // 0.05 // -0 + x == x, x + -0 == x (exact, pre-rounding) - assert_eq!(exact_add(nz.clone(), &x), x); - assert_eq!(exact_add(x.clone(), &nz), x); - assert_eq!(exact_add(nz.clone(), &nz), Repr::<10>::neg_zero()); + assert_eq!(&nz + &x, x); + assert_eq!(&x + &nz, x); + assert_eq!(&nz + &nz, Repr::<10>::neg_zero()); // 1 + -1 cancels to +0 (not -0) - assert_eq!(exact_add(r::<10>(1, 0), &r::<10>(-1, 0)), Repr::<10>::zero()); + assert_eq!(&r::<10>(1, 0) + &r::<10>(-1, 0), Repr::<10>::zero()); } } diff --git a/float/src/lib.rs b/float/src/lib.rs index e03d79cd..6f7091a9 100644 --- a/float/src/lib.rs +++ b/float/src/lib.rs @@ -87,6 +87,7 @@ mod mul; pub mod ops; mod parse; mod repr; +mod repr_ops; mod root; pub mod round; mod round_ops; diff --git a/float/src/mul.rs b/float/src/mul.rs index 10a4543f..44f5530e 100644 --- a/float/src/mul.rs +++ b/float/src/mul.rs @@ -10,53 +10,6 @@ use crate::{ }; use core::ops::{Mul, MulAssign}; -/// Raw product of two finite reprs, attaching the XOR sign of the operands to a zero product -/// (the significand product alone is `+0`, losing the sign). -/// -/// Returns an error when the result exponent overflows or underflows `isize`. -fn make_mul_repr(lhs: &Repr, rhs: &Repr) -> Result, FpError> { - let significand = &lhs.significand * &rhs.significand; - if significand.is_zero() { - return Ok(if lhs.sign() != rhs.sign() { - Repr::neg_zero() - } else { - Repr::zero() - }); - } - let sign = if lhs.sign() != rhs.sign() { - Sign::Negative - } else { - Sign::Positive - }; - let exponent = lhs.exponent.checked_add(rhs.exponent).ok_or_else(|| { - debug_assert!( - lhs.exponent.is_positive() == rhs.exponent.is_positive(), - "checked_add overflow with mixed-sign exponents is impossible" - ); - if lhs.exponent > 0 { - FpError::Overflow(sign) - } else { - FpError::Underflow(sign) - } - })?; - Repr::new(significand, exponent).check_finite_exponent() -} - -macro_rules! unwrap_mul_repr { - ($result:expr, $context:expr) => { - match $result { - Ok(r) => r, - Err(FpError::Overflow(sign)) => { - return FBig::new(Repr::infinity_with_sign(sign), $context); - } - Err(FpError::Underflow(sign)) => { - return FBig::new(Repr::zero_with_sign(sign), $context); - } - Err(_) => unreachable!(), - } - }; -} - impl Mul<&FBig> for &FBig { type Output = FBig; @@ -65,7 +18,10 @@ impl Mul<&FBig> for &FBig { assert_finite_operands(&self.repr, &rhs.repr); let context = Context::max(self.context, rhs.context); - let repr = unwrap_mul_repr!(make_mul_repr(&self.repr, &rhs.repr), context); + let repr = &self.repr * &rhs.repr; + if repr.is_infinite() { + return FBig::new(repr, context); + } FBig::new(context.repr_round(repr).value(), context) } } @@ -78,7 +34,10 @@ impl Mul<&FBig> for FBig { assert_finite_operands(&self.repr, &rhs.repr); let context = Context::max(self.context, rhs.context); - let repr = unwrap_mul_repr!(make_mul_repr(&self.repr, &rhs.repr), context); + let repr = &self.repr * &rhs.repr; + if repr.is_infinite() { + return FBig::new(repr, context); + } FBig::new(context.repr_round(repr).value(), context) } } @@ -91,7 +50,10 @@ impl Mul> for &FBig { assert_finite_operands(&self.repr, &rhs.repr); let context = Context::max(self.context, rhs.context); - let repr = unwrap_mul_repr!(make_mul_repr(&self.repr, &rhs.repr), context); + let repr = &self.repr * &rhs.repr; + if repr.is_infinite() { + return FBig::new(repr, context); + } FBig::new(context.repr_round(repr).value(), context) } } @@ -104,7 +66,10 @@ impl Mul> for FBig { assert_finite_operands(&self.repr, &rhs.repr); let context = Context::max(self.context, rhs.context); - let repr = unwrap_mul_repr!(make_mul_repr(&self.repr, &rhs.repr), context); + let repr = &self.repr * &rhs.repr; + if repr.is_infinite() { + return FBig::new(repr, context); + } FBig::new(context.repr_round(repr).value(), context) } } @@ -205,7 +170,17 @@ impl Context { rhs }; - let repr = make_mul_repr(lhs_repr, rhs_repr)?; + let repr = lhs_repr * rhs_repr; + let repr = if repr.is_infinite() { + return Err(FpError::Overflow(repr.sign())); + } else if repr.significand.is_zero() + && !lhs_repr.significand.is_zero() + && !rhs_repr.significand.is_zero() + { + return Err(FpError::Underflow(repr.sign())); + } else { + repr + }; Ok(self.repr_round(repr).map(|v| FBig::new(v, *self))) } diff --git a/float/src/repr.rs b/float/src/repr.rs index f6f8510b..20913bac 100644 --- a/float/src/repr.rs +++ b/float/src/repr.rs @@ -45,26 +45,6 @@ pub struct Repr { pub(crate) exponent: isize, } -impl PartialEq for Repr { - /// Two representations are equal when they denote the same value. In particular `+0` - /// and `-0` compare equal, as do two infinities of the same sign. - #[inline] - fn eq(&self, other: &Self) -> bool { - if self.significand.is_zero() && other.significand.is_zero() { - let (self_inf, other_inf) = (self.is_infinite(), other.is_infinite()); - match (self_inf, other_inf) { - (true, true) => self.sign() == other.sign(), - (false, false) => true, // both are ±0 - _ => false, // one is zero, the other is infinite - } - } else { - self.significand == other.significand && self.exponent == other.exponent - } - } -} - -impl Eq for Repr {} - /// The context containing runtime information for the floating point number and its operations. /// /// The context currently consists of a *precision limit* and a *rounding mode*. All the operation diff --git a/float/src/repr_ops.rs b/float/src/repr_ops.rs new file mode 100644 index 00000000..9253795e --- /dev/null +++ b/float/src/repr_ops.rs @@ -0,0 +1,324 @@ +//! Core trait impls for [`Repr`]: [`PartialEq`]/[`Eq`] (value equality — `+0`/`-0` and same-sign +//! infinities compare equal), [`Neg`], and exact [`Add`]/[`Sub`]/[`Mul`]. +//! +//! A [`Repr`] carries no precision limit, so the arithmetic ops are lossless (no rounding). These +//! are the shared primitives the crate reaches for whenever it needs an exact intermediate — the +//! Ziv containment test, the correctly-rounded `Sum`, and the `FBig` multiply path. +//! +//! Each arithmetic operator's logic lives in the `&Repr`-by-`&Repr` primary impl; the val/ref +//! forwarders delegate to it without cloning (a `Repr`'s significand is heap-backed, so the ref/ref +//! form is the no-extra-allocation path). [`Mul`] saturates exponent overflow/underflow to the +//! signed infinity/zero sentinels so the operator is infallible; the precision-limited +//! [`Context`](crate::Context) multiply re-derives the [`FpError`] it needs from that saturated +//! result. + +use core::cmp::Ordering; +use core::ops::{Add, Mul, Neg, Sub}; + +use dashu_base::Sign; + +use crate::error::FpError; +use crate::repr::{Repr, Word}; +use crate::utils::shl_digits; + +impl PartialEq for Repr { + /// Two representations are equal when they denote the same value. In particular `+0` + /// and `-0` compare equal, as do two infinities of the same sign. + #[inline] + fn eq(&self, other: &Self) -> bool { + if self.significand.is_zero() && other.significand.is_zero() { + let (self_inf, other_inf) = (self.is_infinite(), other.is_infinite()); + match (self_inf, other_inf) { + (true, true) => self.sign() == other.sign(), + (false, false) => true, // both are ±0 + _ => false, // one is zero, the other is infinite + } + } else { + self.significand == other.significand && self.exponent == other.exponent + } + } +} + +impl Eq for Repr {} + +impl Neg for Repr { + type Output = Self; + #[inline] + fn neg(self) -> Self::Output { + Repr::neg(self) + } +} + +impl Add<&Repr> for &Repr { + type Output = Repr; + + #[inline] + fn add(self, rhs: &Repr) -> Repr { + debug_assert!(self.is_finite()); + debug_assert!(rhs.is_finite()); + + // Zero operands short-circuit so a `-0` operand's sentinel exponent (-1) can't bleed into + // the result exponent — `precise_sum` (the `Sum` impl) relies on this. The aligned path + // below would compute the same value but would rebuild via `Repr::new`, normalizing away + // the operand's own representation. + if self.significand.is_zero() { + return rhs.clone(); + } + if rhs.significand.is_zero() { + return self.clone(); + } + + // Result exponent is min(lhs, rhs); shift the larger-exponent significand up (appending + // trailing base-`B` zero-digits is lossless) and add. `IBig` add handles opposite signs. + match self.exponent.cmp(&rhs.exponent) { + Ordering::Equal => Repr::new(&self.significand + &rhs.significand, self.exponent), + Ordering::Greater => Repr::new( + shl_digits::(&self.significand, (self.exponent - rhs.exponent) as usize) + + &rhs.significand, + rhs.exponent, + ), + Ordering::Less => Repr::new( + &self.significand + + shl_digits::(&rhs.significand, (rhs.exponent - self.exponent) as usize), + self.exponent, + ), + } + } +} + +impl Add<&Repr> for Repr { + type Output = Repr; + #[inline] + fn add(self, rhs: &Repr) -> Repr { + (&self) + rhs + } +} +impl Add> for &Repr { + type Output = Repr; + #[inline] + fn add(self, rhs: Repr) -> Repr { + self + &rhs + } +} +impl Add> for Repr { + type Output = Repr; + #[inline] + fn add(self, rhs: Repr) -> Repr { + (&self) + &rhs + } +} + +impl Sub<&Repr> for &Repr { + type Output = Repr; + + #[inline] + fn sub(self, rhs: &Repr) -> Repr { + debug_assert!(self.is_finite()); + debug_assert!(rhs.is_finite()); + + // Zero short-circuits: `x - 0 = x` (keep x), `0 - x = -x` (negate x). As with `Add`, this + // keeps a zero operand's own representation rather than rebuilding via `Repr::new`. + if rhs.significand.is_zero() { + return self.clone(); + } + if self.significand.is_zero() { + return rhs.clone().neg(); + } + + // Mirror `Add`: align to the smaller exponent, then subtract significands. + match self.exponent.cmp(&rhs.exponent) { + Ordering::Equal => Repr::new(&self.significand - &rhs.significand, self.exponent), + Ordering::Greater => Repr::new( + shl_digits::(&self.significand, (self.exponent - rhs.exponent) as usize) + - &rhs.significand, + rhs.exponent, + ), + Ordering::Less => Repr::new( + &self.significand + - shl_digits::(&rhs.significand, (rhs.exponent - self.exponent) as usize), + self.exponent, + ), + } + } +} + +impl Sub<&Repr> for Repr { + type Output = Repr; + #[inline] + fn sub(self, rhs: &Repr) -> Repr { + (&self) - rhs + } +} +impl Sub> for &Repr { + type Output = Repr; + #[inline] + fn sub(self, rhs: Repr) -> Repr { + self - &rhs + } +} +impl Sub> for Repr { + type Output = Repr; + #[inline] + fn sub(self, rhs: Repr) -> Repr { + (&self) - &rhs + } +} + +impl Mul<&Repr> for &Repr { + type Output = Repr; + + #[inline] + fn mul(self, rhs: &Repr) -> Repr { + debug_assert!(self.is_finite()); + debug_assert!(rhs.is_finite()); + + let significand = &self.significand * &rhs.significand; + if significand.is_zero() { + // The product significand is `+0`; attach the XOR sign of the operands. + return if self.sign() != rhs.sign() { + Repr::neg_zero() + } else { + Repr::zero() + }; + } + let sign = if self.sign() != rhs.sign() { + Sign::Negative + } else { + Sign::Positive + }; + // Exponent = lhs + rhs; saturate an `isize` overflow to the signed infinity/zero sentinel + // so the operator stays infallible (unreachable for real inputs — it needs operands with + // exponents ~±2^62). `Context::mul` re-derives the `FpError` from this saturated result. + let exponent = match self.exponent.checked_add(rhs.exponent) { + Some(e) => e, + None => { + debug_assert!( + self.exponent.is_positive() == rhs.exponent.is_positive(), + "checked_add overflow with mixed-sign exponents is impossible" + ); + return if self.exponent > 0 { + Repr::infinity_with_sign(sign) + } else { + Repr::zero_with_sign(sign) + }; + } + }; + match Repr::new(significand, exponent).check_finite_exponent() { + Ok(r) => r, + Err(FpError::Overflow(s)) => Repr::infinity_with_sign(s), + Err(FpError::Underflow(s)) => Repr::zero_with_sign(s), + Err(_) => unreachable!(), + } + } +} + +impl Mul<&Repr> for Repr { + type Output = Repr; + #[inline] + fn mul(self, rhs: &Repr) -> Repr { + (&self) * rhs + } +} +impl Mul> for &Repr { + type Output = Repr; + #[inline] + fn mul(self, rhs: Repr) -> Repr { + self * &rhs + } +} +impl Mul> for Repr { + type Output = Repr; + #[inline] + fn mul(self, rhs: Repr) -> Repr { + (&self) * &rhs + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dashu_int::IBig; + + fn r(sig: i128, exp: isize) -> Repr { + Repr::new(IBig::from(sig), exp) + } + + #[test] + fn add_same_exponent() { + assert_eq!(&r::<10>(3, 0) + &r::<10>(4, 0), r::<10>(7, 0)); + } + + #[test] + fn add_aligns_exponents() { + // 3 + 0.4 = 3.4 + assert_eq!(&r::<10>(3, 0) + &r::<10>(4, -1), r::<10>(34, -1)); + // 1 + 0.00001 = 1.00001 (exact; the small operand's digits are all retained) + assert_eq!(&r::<10>(1, 0) + &r::<10>(1, -5), r::<10>(100001, -5)); + } + + #[test] + fn add_neg_zero_is_identity() { + let nz = Repr::<10>::neg_zero(); + let x = r::<10>(5, -2); + assert_eq!(&nz + &x, x); + assert_eq!(&x + &nz, x); + // -0 + -0 = -0: the zero short-circuit returns the other operand unchanged. + assert_eq!(&nz + &nz, Repr::<10>::neg_zero()); + } + + #[test] + fn add_cancellation_is_positive_zero() { + let z = &r::<10>(1, 0) + &r::<10>(-1, 0); + assert_eq!(z, Repr::<10>::zero()); + assert!(z.is_pos_zero()); + } + + #[test] + fn sub_basic() { + assert_eq!(&r::<10>(5, 0) - &r::<10>(3, 0), r::<10>(2, 0)); + assert_eq!(&r::<10>(3, 0) - &r::<10>(5, 0), r::<10>(-2, 0)); + // x - 0 = x (subtraction is a + (-0); the zero short-circuit keeps x's representation) + assert_eq!(&r::<10>(5, 0) - &Repr::<10>::zero(), r::<10>(5, 0)); + } + + #[test] + fn mul_basic() { + assert_eq!(&r::<10>(3, 0) * &r::<10>(4, 0), r::<10>(12, 0)); + // significands multiply, exponents add: 3e2 * 2e-1 = 6e1 + assert_eq!(&r::<10>(3, 2) * &r::<10>(2, -1), r::<10>(6, 1)); + assert_eq!(&r::<2>(3, 0) * &r::<2>(3, 0), r::<2>(9, 0)); + } + + #[test] + fn mul_zero_product_sign() { + // (+0) * (+5) = +0 + assert_eq!(&Repr::<10>::zero() * &r::<10>(5, 0), Repr::<10>::zero()); + // (+0) * (-5) = -0 (the XOR sign of the operands is attached to the zero product) + let prod = &Repr::<10>::zero() * &r::<10>(-5, 0); + assert!(prod.is_neg_zero()); + } + + #[test] + fn ref_val_combos() { + let a = r::<10>(2, 0); + let b = r::<10>(3, 0); + assert_eq!(&a + &b, r::<10>(5, 0)); + assert_eq!(a.clone() + &b, r::<10>(5, 0)); + assert_eq!(&a + b.clone(), r::<10>(5, 0)); + assert_eq!(a.clone() + b.clone(), r::<10>(5, 0)); + + let c = r::<10>(7, 0); + let d = r::<10>(4, 0); + assert_eq!(&c - &d, r::<10>(3, 0)); + assert_eq!(c.clone() - &d, r::<10>(3, 0)); + assert_eq!(&c - d.clone(), r::<10>(3, 0)); + assert_eq!(c.clone() - d.clone(), r::<10>(3, 0)); + + let e = r::<10>(6, 0); + let f = r::<10>(7, 0); + assert_eq!(&e * &f, r::<10>(42, 0)); + assert_eq!(e.clone() * &f, r::<10>(42, 0)); + assert_eq!(&e * f.clone(), r::<10>(42, 0)); + assert_eq!(e.clone() * f.clone(), r::<10>(42, 0)); + } +} diff --git a/float/src/sign.rs b/float/src/sign.rs index dd46c3fe..278cf2d1 100644 --- a/float/src/sign.rs +++ b/float/src/sign.rs @@ -60,14 +60,6 @@ impl FBig { } } -impl Neg for Repr { - type Output = Self; - #[inline] - fn neg(self) -> Self::Output { - Repr::neg(self) - } -} - impl Neg for FBig { type Output = Self; #[inline] diff --git a/float/src/ziv.rs b/float/src/ziv.rs index 240a1d2c..d3528126 100644 --- a/float/src/ziv.rs +++ b/float/src/ziv.rs @@ -14,9 +14,16 @@ //! digits. The loop provably terminates (a true tie is resolved deterministically by the mode), //! with a large sanity cap as an unreachable backstop. +use core::cmp::Ordering; + use dashu_base::Approximation::*; -use crate::{fbig::FBig, repr::Context, round::ErrorBounds, round::Rounded}; +use crate::{ + fbig::FBig, + repr::{Context, Repr}, + round::ErrorBounds, + round::Rounded, +}; use dashu_int::Word; /// Maximum number of Ziv retries before falling back to the best-effort rounded value. @@ -68,7 +75,7 @@ impl Context { // `with_precision` consumes `a`, but the containment test still needs it, so round a // clone and keep the original for the interval check. let candidate = a.clone().with_precision(self.precision); - if Self::contained::(&a, &e, candidate.value_ref()) { + if Self::contained::(&a.repr, &e.repr, candidate.value_ref()) { return candidate; } last = Some(candidate); @@ -87,28 +94,39 @@ impl Context { } /// Containment test: is the approximation's error interval `[a − e, a + e]` entirely inside - /// the rounding preimage of `y` (every real in `[y − L, y + R]` rounds to `y` under `R`)? + /// the rounding preimage of `y` (every real in `[y − lb, y + rb]` rounds to `y` under `R`)? + /// + /// `a` and `e` are the working-precision approximation and its provable error radius; `y` is + /// the candidate rounded to the target precision (kept as an [`FBig`] only because + /// [`ErrorBounds::error_bounds`] is defined on [`FBig`]). The interval arithmetic runs on the + /// raw [`Repr`]s, which carry no precision limit, so the additions are lossless — there is no + /// rounding that could drop a guard digit and mis-decide (a wrong call here yields a wrong + /// ULP). The old path promoted every value to unlimited precision via `with_precision(0)`; + /// the [`Repr`]s are already exact, so that was a chain of no-op clones, now removed. /// - /// The arithmetic is done at unlimited precision (an exact no-op promotion via - /// [`FBig::with_precision`](crate::FBig)`(0)`), so the comparison cannot lose a guard digit - /// and mis-decide — a soundness requirement, since a wrong decision here yields a wrong ULP. - fn contained(a: &FBig, e: &FBig, y: &FBig) -> bool { + /// The test compares sums rather than differences — algebraically identical for exact + /// arithmetic, and it reads as a single shared inequality per endpoint: + /// `a − e ≥ y − lb ⟺ a + lb ≥ y + e` + /// `a + e ≤ y + rb ⟺ y + rb ≥ a + e` + fn contained(a: &Repr, e: &Repr, y: &FBig) -> bool { let (lb, rb, incl_l, incl_r) = R::error_bounds::(y); - // Promote to unlimited precision so the interval arithmetic is exact. - let a = a.clone().with_precision(0).value(); - let e = e.clone().with_precision(0).value(); - let y = y.clone().with_precision(0).value(); - let lb = lb.with_precision(0).value(); - let rb = rb.with_precision(0).value(); - - // [a − e, a + e] ⊆ [y − lb, y + rb], respecting each endpoint's inclusivity. - let lo = &a - &e; // a − e - let hi = &a + &e; // a + e - let pre_lo = &y - &lb; // y − L - let pre_hi = &y + &rb; // y + R - let left_ok = if incl_l { lo >= pre_lo } else { lo > pre_lo }; - let right_ok = if incl_r { hi <= pre_hi } else { hi < pre_hi }; + let y = &y.repr; + let lb = lb.into_repr(); + let rb = rb.into_repr(); + + let left = (a + &lb).cmp(&(y + e)); + let right = (y + &rb).cmp(&(a + e)); + let left_ok = if incl_l { + left != Ordering::Less + } else { + left == Ordering::Greater + }; + let right_ok = if incl_r { + right != Ordering::Less + } else { + right == Ordering::Greater + }; left_ok && right_ok } } From a028151ddd29ff63853c0ca1257c3dc1664ce1a0 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Mon, 20 Jul 2026 09:35:52 +0800 Subject: [PATCH 04/21] Ziv loop for trigonometric, hyperbolic, and hypot (powf deferred) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the remaining real transcendentals to guaranteed-correct rounding via the Ziv retry loop (`Context::ziv`/`ziv_pair`): - Trig (sin, cos, sin_cos, tan, asin, acos, atan, atan2): extract near-correct `_compute` series cores and wrap them in Ziv. The argument reduction folds a `|k|·ulp(π/2)` reduction-error term into the radius so the containment test stays sound for huge `|x|`. - Hyperbolic (sinh, cosh, sinh_cosh, tanh, asinh, acosh, atanh): composition-based, treating the Ziv-correct `exp_m1`/`ln_1p` as black boxes; the unreachable exp-overflow case is hoisted via a shared `exp_overflows` probe (closures can't return `Err`). - `hypot`: composition-based (sqrt attenuates), small-constant radius. `sin_cos`/`sinh_cosh` certify both halves via the new `ziv_pair` driver. `powf` is left near-correct — its `exp(y·ln x)` amplification makes the data-dependent radius converge poorly for large results, so a dedicated treatment is deferred. Bound propagation: the migrated `Context`/`FBig`/`CachedFBig` methods move to `R: ErrorBounds`; dashu-cmplx's complex `sqrt`/`norm`/`abs`/ `arg` follow (they route through `hypot`/`atan2`). Tests: 20 exact-oracle soundness tests (`f(x)@p == f(x)@2p re-rounded`) covering every migrated function; existing trig/hyper/exp suites pass. Docs: float CHANGELOG, V1-ROADMAP, and bilingual compliance/faq updated. Co-Authored-By: Claude --- V1-ROADMAP.md | 14 +- complex/src/cbig_cached_ops.rs | 32 +- complex/src/misc.rs | 57 ++-- complex/src/root.rs | 6 +- float/CHANGELOG.md | 11 + float/src/exp.rs | 31 +- float/src/fbig_cached_ops.rs | 39 ++- float/src/math/hyper.rs | 273 ++++++++++------- float/src/math/trig.rs | 483 ++++++++++++++++--------------- float/src/root.rs | 53 ++-- float/src/ziv.rs | 67 +++++ float/tests/exp_log_root_prop.rs | 153 ++++++++++ guide-zh/src/compliance.md | 2 +- guide-zh/src/faq.md | 2 +- guide/src/compliance.md | 2 +- guide/src/faq.md | 2 +- 16 files changed, 793 insertions(+), 434 deletions(-) diff --git a/V1-ROADMAP.md b/V1-ROADMAP.md index 3ad4139c..2a4e4709 100644 --- a/V1-ROADMAP.md +++ b/V1-ROADMAP.md @@ -33,11 +33,15 @@ are longer-term goals. File:line references are anchors from the v0.5.0 tree and ### Correctness -- **Guaranteed-correct rounding (Ziv retry loop).** ✅ *Partially delivered.* `exp`, `exp_m1`, - `ln`, `ln_1p` are now guaranteed-correctly rounded via a Ziv retry loop in `dashu-float` - (`Context::ziv`, driven by the `ErrorBounds` preimage). Remaining: trig, hyperbolic, `powf`, - `hypot`, and inheriting the loop across `dashu-cmplx`'s complex transcendentals (which currently - route through the now-Ziv-backed real primitives but aren't themselves Ziv-wrapped). +- **Guaranteed-correct rounding (Ziv retry loop).** ✅ *Delivered for all real transcendentals + except `powf`.* `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric family (`sin`, `cos`, `sin_cos`, + `tan`, `asin`, `acos`, `atan`, `atan2`), the hyperbolic family (`sinh`, `cosh`, `sinh_cosh`, + `tanh`, `asinh`, `acosh`, `atanh`), and `hypot` are guaranteed-correctly rounded via a Ziv retry + loop (`Context::ziv`/`ziv_pair`, driven by the `ErrorBounds` preimage). Series transcendentals + (trig, atan) use near-correct `_compute` cores the wrapper certifies; composition transcendentals + treat the Ziv-correct primitives as black boxes. Remaining: `powf` (its `exp(y·ln x)` amplification + needs a dedicated radius treatment), and `dashu-cmplx`'s complex transcendental *wrappers* (which + route through these now-correct real primitives but aren't themselves Ziv-wrapped). - **Signed-zero preservation in `CBig` zero short-circuits.** `sin_cos` and `sqr` take a fast path on exactly-zero input that returns `+0` components, so several Annex-G / IEEE signed-zero cases are not preserved (all numerically equal to `+0`, hence deferred): diff --git a/complex/src/cbig_cached_ops.rs b/complex/src/cbig_cached_ops.rs index 99ec59cd..69775deb 100644 --- a/complex/src/cbig_cached_ops.rs +++ b/complex/src/cbig_cached_ops.rs @@ -374,7 +374,6 @@ macro_rules! delegate_to_cbig { impl CachedCBig { // non-cache delegations delegate_to_cbig!(sqr); - delegate_to_cbig!(sqrt); delegate_to_cbig!(conj); delegate_to_cbig!(proj); delegate_to_cbig!(mul_i(negative: bool)); @@ -385,6 +384,23 @@ impl CachedCBig { pub fn norm(&self) -> FBig { self.cbig.norm() } +} + +// Transcendentals route through the Ziv-backed (or Ziv-dependent) real/complex methods, which +// require `R: ErrorBounds`. +impl CachedCBig { + // cache-threading transcendentals + forward_cached!(ln => log); + forward_cached!(exp => exp); + forward_cached!(sin => sin); + forward_cached!(cos => cos); + forward_cached!(tan => tan); + forward_cached!(asin => asin); + forward_cached!(acos => acos); + forward_cached!(atan => atan); + + // `sqrt` and `abs` route through `Context::hypot`, which is now Ziv-backed (`R: ErrorBounds`). + delegate_to_cbig!(sqrt); /// The modulus `|z|` (a real [`FBig`]) (see [`CBig::abs`]). #[inline] @@ -401,20 +417,6 @@ impl CachedCBig { ctx.float() .unwrap_fp(ctx.arg::(&self.cbig, Some(&mut *c))) } -} - -// Transcendentals route through the Ziv-backed (or Ziv-dependent) real/complex methods, which -// require `R: ErrorBounds`. -impl CachedCBig { - // cache-threading transcendentals - forward_cached!(ln => log); - forward_cached!(exp => exp); - forward_cached!(sin => sin); - forward_cached!(cos => cos); - forward_cached!(tan => tan); - forward_cached!(asin => asin); - forward_cached!(acos => acos); - forward_cached!(atan => atan); /// Complex power `self^w` (see [`CBig::powf`]). Threads the cache into the inner `log`/`exp`. #[inline] diff --git a/complex/src/misc.rs b/complex/src/misc.rs index 85531016..8720f72c 100644 --- a/complex/src/misc.rs +++ b/complex/src/misc.rs @@ -4,7 +4,7 @@ use crate::cbig::CBig; use crate::repr::{exact, CfpResult, Context}; use core::ops::Neg; use dashu_base::Sign; -use dashu_float::round::Round; +use dashu_float::round::{ErrorBounds, Round}; use dashu_float::{FBig, FpResult, Repr}; use dashu_int::Word; @@ -43,25 +43,6 @@ impl CBig { pub fn norm(&self) -> FBig { self.context.float().unwrap_fp(self.context.norm(self)) } - - /// The modulus `|z| = sqrt(re² + im²)` (a real [`FBig`]). A thin composition over - /// [`dashu_float::Context::hypot`] (the overflow-safe scaled sum-of-squares), evaluated at guard - /// precision and re-rounded. Near-correctly rounded. - /// - /// # Panics - /// - /// Panics if the precision is unlimited. - #[inline] - pub fn abs(&self) -> FBig { - self.context.float().unwrap_fp(self.context.abs(self)) - } - - /// The argument (phase) `atan2(im, re) ∈ ]-π, π]`. The branch cut lies on `]−∞, 0]`; signed zero - /// and infinities are handled per the C99 Annex G `atan2` table (reused from `dashu-float`). - #[inline] - pub fn arg(&self) -> FBig { - self.context.float().unwrap_fp(self.context.arg(self, None)) - } } impl Neg for CBig { @@ -145,6 +126,21 @@ impl Context { let n = gctx.unwrap_fp(gctx.add(re2.repr(), im2.repr())); Ok(n.with_precision(self.precision())) } +} + +impl Context { + /// The modulus `|z| = hypot(re, im)` (context layer). Near-correctly rounded; returns `+∞` for + /// an infinite input. Thin composition over [`dashu_float::Context::hypot`] (now Ziv-correctly + /// rounded). + /// + /// # Panics + /// + /// Panics if the precision is unlimited. + pub fn abs(&self, z: &CBig) -> FpResult> { + let gctx = self.guard(ABS_GUARD); + let h = gctx.hypot(z.re(), z.im())?; + Ok(h.value().with_precision(self.precision())) + } /// The argument `atan2(im, re)` (context layer). Delegates to `dashu-float`'s Annex-G `atan2`; /// the cache threads into it (the convenience layer passes `None`). @@ -155,17 +151,26 @@ impl Context { ) -> FpResult> { self.float().atan2(z.im(), z.re(), cache) } +} - /// The modulus `|z| = hypot(re, im)` (context layer). Near-correctly rounded; returns `+∞` for - /// an infinite input. Thin composition over [`dashu_float::Context::hypot`]. +impl CBig { + /// The modulus `|z| = sqrt(re² + im²)` (a real [`FBig`]). A thin composition over + /// [`dashu_float::Context::hypot`] (the overflow-safe scaled sum-of-squares, Ziv-correctly + /// rounded), evaluated at guard precision and re-rounded. /// /// # Panics /// /// Panics if the precision is unlimited. - pub fn abs(&self, z: &CBig) -> FpResult> { - let gctx = self.guard(ABS_GUARD); - let h = gctx.hypot(z.re(), z.im())?; - Ok(h.value().with_precision(self.precision())) + #[inline] + pub fn abs(&self) -> FBig { + self.context.float().unwrap_fp(self.context.abs(self)) + } + + /// The argument (phase) `atan2(im, re) ∈ ]-π, π]`. The branch cut lies on `]−∞, 0]`; signed zero + /// and infinities are handled per the C99 Annex G `atan2` table (reused from `dashu-float`). + #[inline] + pub fn arg(&self) -> FBig { + self.context.float().unwrap_fp(self.context.arg(self, None)) } } diff --git a/complex/src/root.rs b/complex/src/root.rs index dcb2e041..2335b37a 100644 --- a/complex/src/root.rs +++ b/complex/src/root.rs @@ -3,7 +3,7 @@ use crate::cbig::CBig; use crate::repr::{combine_parts, exact, CfpResult, Context}; use dashu_base::Sign; -use dashu_float::round::Round; +use dashu_float::round::{ErrorBounds, Round}; use dashu_float::{FBig, Repr}; use dashu_int::Word; @@ -19,7 +19,7 @@ fn signed_inf(sign: Sign) -> Repr { } } -impl Context { +impl Context { /// Principal square root of a complex number (context layer). /// /// The result has non-negative real part; when the real part is zero the imaginary part is @@ -68,7 +68,7 @@ impl Context { } } -impl CBig { +impl CBig { /// Principal square root (convenience layer). /// /// # Panics diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index c492fb67..32eb3240 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -21,6 +21,17 @@ - **Tightened `exp` guard digits** (now a performance knob, since Ziv — not the guard count — guarantees correctness): the `Bⁿ`-powering guard is halved from `2n` to `n` and the series guard drops its conservative `+ 2`. +- **Guaranteed-correct rounding for most remaining transcendentals** via the Ziv loop: the + trigonometric family (`sin`, `cos`, `sin_cos`, `tan`, `asin`, `acos`, `atan`, `atan2`), the + hyperbolic family (`sinh`, `cosh`, `sinh_cosh`, `tanh`, `asinh`, `acosh`, `atanh`), and `hypot`. + (`powf` remains near-correct: its `exp(y·ln x)` composition amplifies the rounding by the result + magnitude, and the resulting data-dependent radius makes the Ziv containment test converge poorly + for large results — a dedicated radius treatment is deferred.) The trig series (`sin`/`cos`/`atan`) + are factored into near-correct `_compute` cores like `exp_compute`; the composition-based functions + treat the now-Ziv-correct `exp`/`ln`/`atan` as black boxes and count only their arithmetic. The + trig argument reduction folds a `|k|·ulp(π/2)` reduction-error term into the radius so the + containment test stays sound for huge `|x|`. `ziv_pair` certifies both halves of `sin_cos`/ + `sinh_cosh`. ### Change - **(breaking, bound)** `Context::exp`/`exp_m1`/`ln`/`ln_1p` (and `powf`, the hyperbolic family, diff --git a/float/src/exp.rs b/float/src/exp.rs index 0c9705a6..09f5378d 100644 --- a/float/src/exp.rs +++ b/float/src/exp.rs @@ -279,6 +279,25 @@ impl Context { } } +/// Hoisted `exp` overflow probe for the Ziv closures (which can't return `Err`). Returns `true` +/// when `exp(x)` is outside the finite exponent range — astronomically large `|x|` (the reduction +/// quotient `s = x/ln B` overflows `isize`). True for both signs of huge `x` (the quotient +/// *magnitude* overflows `isize`). Shared by `exp_internal`, `powf`, and the hyperbolic functions. +pub(crate) fn exp_overflows( + ctx: &Context, + x: &Repr, + cache: &mut Option<&mut ConstCache>, +) -> bool { + if x.log2_est().abs() <= 61.0 { + return false; + } + let probe = Context::::new(ctx.precision + 64); + let logb = probe.ln_base::(reborrow_cache(cache)); + let x_probe = FBig::new(probe.repr_round_ref(x).value(), probe); + let s_probe = x_probe.div_rem_euclid(logb).0; + >::try_from(s_probe).is_err() +} + // `powf`/`exp`/`exp_m1` are correctly rounded (via the Ziv loop for exp/exp_m1, and via the // Ziv-backed ln/exp primitives for powf), so they require `R: ErrorBounds`. impl Context { @@ -338,16 +357,22 @@ impl Context { FBig::ZERO })); } + if base.is_one() { + // pow(1, y) = 1 for any finite y (exp is finite here — infinities were rejected above). + return Ok(Exact(FBig::ONE)); + } if base.sign() == Sign::Negative { // TODO: we should allow negative base when exp is an integer return Err(FpError::OutOfDomain); } - // x^y = exp(y*ln(x)), use a simple rule for guard bits + // x^y = exp(y·ln x). Near-correctly rounded (guard-digit recipe): `ln` and `exp` are + // themselves Ziv-correct at the working precision, so this is within 1 ulp. A Ziv wrapper + // for `powf` is deferred — the data-dependent `exp` amplification (`result · ulp(y·ln x)`) + // makes the containment test converge poorly for large-magnitude results, so it needs a + // dedicated radius treatment before it's worth the cost. let guard_digits = 10 + ceil_usize(self.precision.log2_est()); let work_context = Context::::new(self.precision + guard_digits); - - // ln and exp each consult/extend the shared cache; reborrows are sequential. let ln_val = work_context.unwrap_fp(work_context.ln(base, reborrow_cache(&mut cache))); let mul_val = work_context.unwrap_fp(work_context.mul(ln_val.repr(), exp)); let exp_val = diff --git a/float/src/fbig_cached_ops.rs b/float/src/fbig_cached_ops.rs index 20d6c423..713aa2ca 100644 --- a/float/src/fbig_cached_ops.rs +++ b/float/src/fbig_cached_ops.rs @@ -666,6 +666,25 @@ macro_rules! forward_to_fbig { impl CachedFBig { forward_to_fbig!(sqrt); forward_to_fbig!(inv); + forward_to_fbig!(powi(exp: dashu_int::IBig)); + forward_to_fbig!(sqr); + forward_to_fbig!(cubic); +} + +// Transcendentals that route through the Ziv-backed (or Ziv-dependent) Context methods require +// `R: ErrorBounds` for their correctness guarantee. +impl CachedFBig { + forward_to_context!(ln); + forward_to_context!(ln_1p); + forward_to_context!(exp); + forward_to_context!(exp_m1); + + forward_to_context!(sinh); + forward_to_context!(cosh); + forward_to_context!(tanh); + forward_to_context!(asinh); + forward_to_context!(acosh); + forward_to_context!(atanh); forward_to_context_unwrap!(sin); forward_to_context_unwrap!(cos); @@ -674,10 +693,6 @@ impl CachedFBig { forward_to_context_unwrap!(acos); forward_to_context_unwrap!(atan); - forward_to_fbig!(powi(exp: dashu_int::IBig)); - forward_to_fbig!(sqr); - forward_to_fbig!(cubic); - /// Sine and cosine together (see [`FBig::sin_cos`]). pub fn sin_cos(&self) -> (Self, Self) { let mut guard = self.cache.borrow_mut(); @@ -699,22 +714,6 @@ impl CachedFBig { )); Self::from_fbig(fbig, &self.cache) } -} - -// Transcendentals that route through the Ziv-backed (or Ziv-dependent) Context methods require -// `R: ErrorBounds` for their correctness guarantee. -impl CachedFBig { - forward_to_context!(ln); - forward_to_context!(ln_1p); - forward_to_context!(exp); - forward_to_context!(exp_m1); - - forward_to_context!(sinh); - forward_to_context!(cosh); - forward_to_context!(tanh); - forward_to_context!(asinh); - forward_to_context!(acosh); - forward_to_context!(atanh); /// `self^exp` (see [`FBig::powf`]). pub fn powf(&self, exp: &Self) -> Self { diff --git a/float/src/math/hyper.rs b/float/src/math/hyper.rs index 091009ee..0ffdb69c 100644 --- a/float/src/math/hyper.rs +++ b/float/src/math/hyper.rs @@ -14,6 +14,7 @@ use crate::{ error::{assert_limited_precision, FpError}, + exp::exp_overflows, fbig::FBig, math::{ cache::{reborrow_cache, ConstCache}, @@ -21,8 +22,9 @@ use crate::{ }, repr::{Context, Repr, Word}, round::{ErrorBounds, Round}, + utils::ceil_usize, }; -use dashu_base::{Abs, AbsOrd, Approximation::Exact, Sign}; +use dashu_base::{Abs, AbsOrd, Approximation::Exact, EstimatedLog2, Sign}; impl Context { /// Hyperbolic sine. @@ -39,20 +41,30 @@ impl Context { // sinh(±0) = ±0 return Ok(Exact(FBig::new(signed_zero_repr(x), *self))); } - - // sinh(x) = (exp_m1(x) - exp_m1(-x)) / 2 (cancellation-free) - let work = Context::::new(self.precision + 50); - let x_f = FBig::::new(work.repr_round_ref(x).value(), work); - let neg_x = -x_f.clone(); - let ep = work.exp_m1(&x_f.repr, reborrow_cache(&mut cache)); - let em = work.exp_m1(&neg_x.repr, reborrow_cache(&mut cache)); - match (ep, em) { - (Ok(ep), Ok(em)) => { - Ok(((ep.value() - em.value()) / 2i32).with_precision(self.precision)) - } - // |x| large enough that exp_m1 overflowed: sinh(x) → ±inf (sign of x). - _ => Err(FpError::Overflow(x.sign())), + // Hoist the exp overflow out of the Ziv closure (it can't return Err): sinh(±huge) = ±inf. + if exp_overflows::(self, x, &mut cache) { + return Err(FpError::Overflow(x.sign())); } + + // sinh(x) = (exp_m1(x) - exp_m1(-x)) / 2 (cancellation-free). `exp_m1` is itself Ziv-correct + // at the working precision, so only the subtraction/divide rounding contributes to the + // radius (a few working-ULPs, scaled by the `exp_m1(x) ≈ 2·sinh(x)` magnitude ratio). + let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + Ok(self.ziv(initial_guard, |guard| { + let work = Context::::new(self.precision + guard); + let x_f = FBig::::new(work.repr_round_ref(x).value(), work); + let ep = work + .exp_m1(&x_f.repr, reborrow_cache(&mut cache)) + .unwrap() + .value(); + let em = work + .exp_m1(&(-x_f.clone()).repr, reborrow_cache(&mut cache)) + .unwrap() + .value(); + let result = (ep - em) / 2i32; + let radius = result.ulp() * 12; + (result, radius) + })) } /// Hyperbolic cosine. @@ -71,18 +83,29 @@ impl Context { return Ok(Exact(FBig::new(Repr::one(), *self))); } - // cosh(x) = (exp_m1(x) + exp_m1(-x)) / 2 + 1 (no cancellation: same-sign sum) - let work = Context::::new(self.precision + 50); - let x_f = FBig::::new(work.repr_round_ref(x).value(), work); - let neg_x = -x_f.clone(); - let ep = work.exp_m1(&x_f.repr, reborrow_cache(&mut cache)); - let em = work.exp_m1(&neg_x.repr, reborrow_cache(&mut cache)); - match (ep, em) { - (Ok(ep), Ok(em)) => Ok(((ep.value() + em.value()) / 2i32 + FBig::::ONE) - .with_precision(self.precision)), - // cosh(x) ≥ 0 always, so overflow → +inf regardless of x's sign. - _ => Err(FpError::Overflow(Sign::Positive)), + // Hoist the exp overflow out of the Ziv closure: cosh(±huge) = +inf (always positive). + if exp_overflows::(self, x, &mut cache) { + return Err(FpError::Overflow(Sign::Positive)); } + // cosh(x) = (exp_m1(x) + exp_m1(-x)) / 2 + 1 (no cancellation: same-sign sum). `exp_m1` is + // Ziv-correct at the working precision; the radius is a few working-ULPs (the `exp_m1(x) ≈ + // 2·cosh(x)` magnitude ratio, plus the trailing +1). + let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + Ok(self.ziv(initial_guard, |guard| { + let work = Context::::new(self.precision + guard); + let x_f = FBig::::new(work.repr_round_ref(x).value(), work); + let ep = work + .exp_m1(&x_f.repr, reborrow_cache(&mut cache)) + .unwrap() + .value(); + let em = work + .exp_m1(&(-x_f.clone()).repr, reborrow_cache(&mut cache)) + .unwrap() + .value(); + let result = (ep + em) / 2i32 + FBig::::ONE; + let radius = result.ulp() * 14; + (result, radius) + })) } /// Simultaneously compute `sinh(x)` and `cosh(x)` (context layer). Returns @@ -109,25 +132,31 @@ impl Context { ); } - // sinh = (exp_m1(x) - exp_m1(-x)) / 2; cosh = (exp_m1(x) + exp_m1(-x)) / 2 + 1 - let work = Context::::new(self.precision + 50); - let x_f = FBig::::new(work.repr_round_ref(x).value(), work); - let neg_x = -x_f.clone(); - let ep = work.exp_m1(&x_f.repr, reborrow_cache(&mut cache)); - let em = work.exp_m1(&neg_x.repr, reborrow_cache(&mut cache)); - match (ep, em) { - (Ok(ep), Ok(em)) => { - let ep = ep.value(); - let em = em.value(); - let sinh_val = ((ep.clone() - em.clone()) / 2i32).with_precision(self.precision); - let cosh_val = - ((ep + em) / 2i32 + FBig::::ONE).with_precision(self.precision); - (Ok(sinh_val), Ok(cosh_val)) - } - // |x| large enough that exp_m1 overflowed: - // sinh(x) → ±inf (sign of x), cosh(x) → +inf - _ => (Err(FpError::Overflow(x.sign())), Err(FpError::Overflow(Sign::Positive))), + // Hoist the exp overflow out of the Ziv closure: sinh(±huge) = ±inf, cosh(±huge) = +inf. + if exp_overflows::(self, x, &mut cache) { + return (Err(FpError::Overflow(x.sign())), Err(FpError::Overflow(Sign::Positive))); } + // sinh = (ep - em)/2; cosh = (ep + em)/2 + 1, sharing the two `exp_m1` calls. Certified as a + // pair via `ziv_pair` (retry while either endpoint straddles a boundary). + let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + let (sinh_r, cosh_r) = self.ziv_pair(initial_guard, |guard| { + let work = Context::::new(self.precision + guard); + let x_f = FBig::::new(work.repr_round_ref(x).value(), work); + let ep = work + .exp_m1(&x_f.repr, reborrow_cache(&mut cache)) + .unwrap() + .value(); + let em = work + .exp_m1(&(-x_f.clone()).repr, reborrow_cache(&mut cache)) + .unwrap() + .value(); + let sinh_val = (ep.clone() - em.clone()) / 2i32; + let cosh_val = (ep + em) / 2i32 + FBig::::ONE; + let sinh_radius = sinh_val.ulp() * 12; + let cosh_radius = cosh_val.ulp() * 14; + ((sinh_val, sinh_radius), (cosh_val, cosh_radius)) + }); + (Ok(sinh_r), Ok(cosh_r)) } /// Hyperbolic tangent. @@ -151,20 +180,25 @@ impl Context { return Ok(Exact(FBig::new(signed_zero_repr(x), *self))); } - // tanh(x) = exp_m1(2x) / (exp_m1(2x) + 2). For large negative x, exp_m1(2x) → -1, - // giving -1/(-1+2) = -1; for large positive x, exp_m1(2x) overflows, but - // tanh(+huge) = 1, so short-circuit before the division would yield +inf/+inf = NaN. - let work = Context::::new(self.precision + 50); - let x_f = FBig::::new(work.repr_round_ref(x).value(), work); - let two_x = x_f.clone() * 2i32; - match work.exp_m1(&two_x.repr, reborrow_cache(&mut cache)) { - Err(FpError::Overflow(_)) => Ok(FBig::ONE.with_precision(self.precision)), - Ok(e) => { - let e = e.value(); - Ok((e.clone() / (e.clone() + 2i32)).with_precision(self.precision)) + // tanh(x) = exp_m1(2x) / (exp_m1(2x) + 2). `exp_m1(2x)` is Ziv-correct at the working + // precision. For large positive x it overflows → tanh = +1 (returned inline as an exact + // value); for large negative x, exp_m1(2x) → -1 (finite), so tanh → -1 naturally. + let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + Ok(self.ziv(initial_guard, |guard| { + let work = Context::::new(self.precision + guard); + let x_f = FBig::::new(work.repr_round_ref(x).value(), work); + let two_x = x_f * 2i32; + match work.exp_m1(&two_x.repr, reborrow_cache(&mut cache)) { + Err(FpError::Overflow(_)) => (FBig::::ONE, FBig::::ZERO), // exact +1 + Ok(e) => { + let e = e.value(); + let result = e.clone() / (e + 2i32); + let radius = result.ulp() * 12; + (result, radius) + } + Err(other) => unreachable!("exp_m1 on finite input: {other:?}"), } - Err(other) => Err(other), - } + })) } /// Inverse hyperbolic sine. @@ -182,31 +216,40 @@ impl Context { return Ok(Exact(FBig::new(signed_zero_repr(x), *self))); } - // asinh(x) = sign(x) · ln_1p(|x| + x²/(sqrt(x²+1)+1)). - // The x²/(sqrt+1) form avoids the `sqrt(x²+1) - 1` cancellation near 0. - let work = Context::::new(self.precision + 50); - let x_f = FBig::::new(work.repr_round_ref(x).value(), work); - let sign = x_f.sign(); - let abs_x = x_f.abs(); - let arg = match work.sqr(&abs_x.repr) { - Ok(x_sq) => { - let x_sq = x_sq.value(); - let sqrt_plus_one = work.sqrt(&(x_sq.clone() + FBig::::ONE).repr)?.value() - + FBig::::ONE; - abs_x.clone() + x_sq / sqrt_plus_one - } - // |x| so large that x² overflows: asinh(x) ≈ sign·ln(2|x|) (the √(1+1/x²) - // correction is far below representable precision here). - Err(FpError::Overflow(_)) => { - let ln_val = work - .ln(&(abs_x.clone() * 2i32).repr, reborrow_cache(&mut cache))? - .value(); - return Ok(apply_sign(ln_val, sign).with_precision(self.precision)); - } - Err(other) => return Err(other), - }; - let res = work.ln_1p(&arg.repr, reborrow_cache(&mut cache))?.value(); - Ok(apply_sign(res, sign).with_precision(self.precision)) + // asinh(x) = sign(x) · ln_1p(|x| + x²/(sqrt(x²+1)+1)) — the x²/(sqrt+1) form avoids the + // `sqrt(x²+1) − 1` cancellation near 0. `ln_1p`/`ln`/`sqrt` are Ziv-correct at the working + // precision, so the radius is a few working-ULPs of accumulated arithmetic. The `|x|` so + // large that `x²` overflows arm falls back to the asymptotic `sign·ln(2|x|)`. + let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + Ok(self.ziv(initial_guard, |guard| { + let work = Context::::new(self.precision + guard); + let x_f = FBig::::new(work.repr_round_ref(x).value(), work); + let sign = x_f.sign(); + let abs_x = x_f.abs(); + let res = match work.sqr(&abs_x.repr) { + Ok(x_sq) => { + let x_sq = x_sq.value(); + let sqrt_plus_one = work + .sqrt(&(x_sq.clone() + FBig::::ONE).repr) + .unwrap() + .value() + + FBig::::ONE; + let arg = abs_x.clone() + x_sq / sqrt_plus_one; + work.ln_1p(&arg.repr, reborrow_cache(&mut cache)) + .unwrap() + .value() + } + // |x| so large that x² overflows: asinh(x) ≈ sign·ln(2|x|). + Err(FpError::Overflow(_)) => work + .ln(&(abs_x.clone() * 2i32).repr, reborrow_cache(&mut cache)) + .unwrap() + .value(), + Err(other) => unreachable!("sqr: {other:?}"), + }; + let result = apply_sign(res, sign); + let radius = result.ulp() * 14; + (result, radius) + })) } /// Inverse hyperbolic cosine. Domain: `x ≥ 1`. @@ -234,25 +277,33 @@ impl Context { return Ok(Exact(FBig::new(Repr::zero(), *self))); } - // acosh(x) = ln_1p((x-1) + sqrt((x-1)(x+1))). The (x-1)(x+1) form avoids the - // `x²-1` cancellation near x = 1. - let work = Context::::new(self.precision + 50); - let x_f = FBig::::new(work.repr_round_ref(x).value(), work); - let xm1 = &x_f - FBig::::ONE; - let xp1 = &x_f + FBig::::ONE; - let arg = match work.mul(&xm1.repr, &xp1.repr) { - Ok(prod) => xm1.clone() + work.sqrt(&prod.value().repr)?.value(), - // (x-1)(x+1) overflowed: acosh(x) ≈ ln(2x). - Err(FpError::Overflow(_)) => { - let ln_val = work - .ln(&(x_f.clone() * 2i32).repr, reborrow_cache(&mut cache))? - .value(); - return Ok(ln_val.with_precision(self.precision)); - } - Err(other) => return Err(other), - }; - let res = work.ln_1p(&arg.repr, reborrow_cache(&mut cache))?.value(); - Ok(res.with_precision(self.precision)) + // acosh(x) = ln_1p((x-1) + sqrt((x-1)(x+1))) — the (x-1)(x+1) form avoids the `x²−1` + // cancellation near x = 1. `ln_1p`/`ln`/`sqrt` are Ziv-correct at the working precision; + // the radius is a few working-ULPs (generous for the near-x=1 cancellation). The `(x-1)(x+1)` + // overflow arm falls back to the asymptotic `ln(2x)`. + let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + Ok(self.ziv(initial_guard, |guard| { + let work = Context::::new(self.precision + guard); + let x_f = FBig::::new(work.repr_round_ref(x).value(), work); + let xm1 = &x_f - FBig::::ONE; + let xp1 = &x_f + FBig::::ONE; + let res = match work.mul(&xm1.repr, &xp1.repr) { + Ok(prod) => { + let arg = xm1.clone() + work.sqrt(&prod.value().repr).unwrap().value(); + work.ln_1p(&arg.repr, reborrow_cache(&mut cache)) + .unwrap() + .value() + } + // (x-1)(x+1) overflowed: acosh(x) ≈ ln(2x). + Err(FpError::Overflow(_)) => work + .ln(&(x_f.clone() * 2i32).repr, reborrow_cache(&mut cache)) + .unwrap() + .value(), + Err(other) => unreachable!("mul: {other:?}"), + }; + let radius = res.ulp() * 16; + (res, radius) + })) } /// Inverse hyperbolic tangent. Domain: `-1 < x < 1` (`x = ±1` → ±∞, `|x| > 1` is an error). @@ -278,12 +329,22 @@ impl Context { _ => {} } - // atanh(x) = ln_1p(2x/(1-x)) / 2. - let work = Context::::new(self.precision + 50); - let x_f = FBig::::new(work.repr_round_ref(x).value(), work); - let ratio = (x_f.clone() * 2i32) / (FBig::::ONE - &x_f); - let res = work.ln_1p(&ratio.repr, reborrow_cache(&mut cache))?.value(); - Ok((res / 2i32).with_precision(self.precision)) + // atanh(x) = ln_1p(2x/(1-x)) / 2. `ln_1p` is Ziv-correct at the working precision; the + // radius is a few working-ULPs (generous: the `2x/(1-x)` division amplifies as |x| → 1, but + // the result grows there too, so its ULP keeps the bound sound — Ziv retries near |x|=1). + let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + Ok(self.ziv(initial_guard, |guard| { + let work = Context::::new(self.precision + guard); + let x_f = FBig::::new(work.repr_round_ref(x).value(), work); + let ratio = (x_f.clone() * 2i32) / (FBig::::ONE - &x_f); + let res = work + .ln_1p(&ratio.repr, reborrow_cache(&mut cache)) + .unwrap() + .value(); + let result = res / 2i32; + let radius = result.ulp() * 16; + (result, radius) + })) } } diff --git a/float/src/math/trig.rs b/float/src/math/trig.rs index e9d931b6..87410925 100644 --- a/float/src/math/trig.rs +++ b/float/src/math/trig.rs @@ -14,13 +14,15 @@ use crate::{ FpResult, }, repr::{Context, Repr, Word}, - round::{Round, Rounded}, + round::{ErrorBounds, Round, Rounded}, }; -use core::cmp::Ordering; use core::convert::TryFrom; -use dashu_base::{AbsOrd, Approximation::Exact, RemEuclid, Sign}; +use dashu_base::{Abs, AbsOrd, Approximation::Exact, RemEuclid, Sign, UnsignedAbs}; use dashu_int::IBig; +/// A near-correct value paired with its provable error radius (the Ziv closure contract). +pub(crate) type Rad = (FBig, FBig); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Quadrant { First, @@ -43,19 +45,14 @@ fn signed_zero_normal( Ok(Exact(FBig::::new(zero, *ctx))) } -impl Context { - /// Calculate the internal work context for trigonometric functions based on input magnitude. - /// - /// This ensures we have enough guard digits to prevent catastrophic cancellation - /// during range reduction for large inputs. - fn compute_work_context_trig(self, x: &Repr) -> Self { +impl Context { + /// Work context for trigonometric functions: enough guard digits to absorb the catastrophic + /// cancellation in `x − k·(π/2)` for large `|x|`. `guard` (the Ziv retry's growing margin) + /// replaces the fixed base; `x_mag/10` covers cumulative reduction error scaling with `|x|`. + fn compute_work_context_trig(self, x: &Repr, guard: usize) -> Self { // x_mag estimates m = floor(log_BASE(|x|)) let x_mag = (x.exponent.saturating_add(x.digits_ub() as isize)).max(0) as usize; - - // We need precision + log10(x) digits to maintain 'precision' digits after reduction. - // We add a base of 50 guard digits, plus 10% of x_mag for very large arguments - // to account for cumulative errors in division and multiplication during reduction. - let extra_guards = 50 + x_mag / 10; + let extra_guards = guard + x_mag / 10; let work_precision = self .precision .saturating_add(x_mag) @@ -63,25 +60,37 @@ impl Context { Self::new(work_precision) } - /// Reduces the argument to the first quadrant for trigonometric evaluation. - /// Returns the internal work context, the reduced argument `r`, and the quadrant `k % 4`. + /// Reduces the argument to the first quadrant: `r = x − k·(π/2)` with `r ∈ (−π/4, π/4]`. + /// Returns the work context, `r`, the quadrant `k % 4`, and the **reduction error** — a provable + /// bound on `|r_computed − r_true|` (dominated by `|k|·ulp(half_pi)` for huge `|x|`), which the + /// Ziv wrapper folds into the result radius so the containment test is sound. fn reduce_to_quadrant( self, x: &Repr, + guard: usize, mut cache: Option<&mut ConstCache>, - ) -> (Self, FBig, Quadrant) { - let work_context = self.compute_work_context_trig(x); + ) -> (Self, FBig, Quadrant, FBig) { + let work_context = self.compute_work_context_trig(x, guard); let x_f = FBig::::new(work_context.repr_round(x.clone()).value(), work_context); let pi = work_context.pi::(reborrow_cache(&mut cache)).value(); - let half_pi = &pi / 2; + let half_pi = &pi / 2u8; let x_scaled: FBig = &x_f / &half_pi; let k_f = x_scaled.round(); - let r = x_f - &k_f * half_pi; + let r = x_f - &k_f * &half_pi; // `k_f` is the integer nearest `x_scaled`, so it's exact (or a signed zero // for a tiny argument in (-1, 0), which `IBig::try_from` treats as plain 0). let k = IBig::try_from(k_f).expect("k_f is an exact integer or signed zero"); + // Reduction error bound: the rounded `half_pi` carries `< 1 ulp`, scaled by `|k|`; the `x` + // rounding and the subtraction add a few `r`-ULPs. Computed at the work precision (|k| fits + // in its digits, so this is accurate; the full-ulp factors and the +4 over-estimate) — kept + // off unlimited precision so `tan`'s `/|cos|` radius division stays legal. + let half_pi_ulp = half_pi.ulp(); + let r_ulp = r.ulp(); + let k_abs = k.clone().unsigned_abs(); + let reduction_err = half_pi_ulp * k_abs + r_ulp * 4; + let k_mod_4_big = k.rem_euclid(IBig::from(4)); let Ok(k_mod_4_int) = i8::try_from(k_mod_4_big) else { unreachable!("k % 4 is always in [0, 3]"); @@ -94,7 +103,7 @@ impl Context { _ => unreachable!(), }; - (work_context, r, quadrant) + (work_context, r, quadrant, reduction_err) } /// Calculate the sine of the floating point representation. @@ -107,28 +116,39 @@ impl Context { return Err(FpError::InfiniteInput); } assert_limited_precision(self.precision); - if x.significand.is_zero() { // sin(±0) = ±0 return signed_zero_normal(self, x); } - let (work_context, r, quadrant) = self.reduce_to_quadrant(x, reborrow_cache(&mut cache)); - - // 3. Evaluate the reduced series based on the quadrant - let res = match quadrant { - Quadrant::First => work_context.sin_internal(&r), - Quadrant::Second => work_context.cos_internal(&r), - Quadrant::Third => -work_context.sin_internal(&r), - Quadrant::Fourth => -work_context.cos_internal(&r), - }; - Ok(res.with_precision(self.precision)) + // Ziv: reduce to the first quadrant (the guard grows per retry, enlarging the work precision + // that absorbs the `x − k·(π/2)` cancellation), evaluate the series, and fold the reduction + // error into the radius so the containment test is sound even for huge |x|. + Ok(self.ziv(50, |guard| { + let (work, r, quadrant, reduction_err) = + self.reduce_to_quadrant(x, guard, reborrow_cache(&mut cache)); + let (val, series_radius) = match quadrant { + Quadrant::First => work.sin_compute(&r), + Quadrant::Second => work.cos_compute(&r), + Quadrant::Third => { + let (v, e) = work.sin_compute(&r); + (-v, e) + } + Quadrant::Fourth => { + let (v, e) = work.cos_compute(&r); + (-v, e) + } + }; + (val, series_radius + reduction_err) + })) } - /// Internal Taylor series for sine: S(x) = x - x^3/3! + x^5/5! - ... - fn sin_internal(self, x: &FBig) -> FBig { + /// Near-correct sine series `S(x) = x − x³/3! + x⁵/5! − …` on the reduced argument, returning + /// `(value, error_radius)`. The radius covers series truncation (`< 1 working-ULP` by the break + /// test) plus `~3K` steps of rounding accumulation. Used by the Ziv-backed `sin`/`cos`/`tan`. + fn sin_compute(self, x: &FBig) -> (FBig, FBig) { if x.repr.significand.is_zero() { - return FBig::ZERO; + return (FBig::ZERO, FBig::ZERO); } let x2 = x.sqr(); let mut sum = x.clone(); @@ -148,7 +168,8 @@ impl Context { } k += 1; } - sum + let radius = sum.ulp() * (4 * k + 12); + (sum, radius) } /// Calculate the cosine of the floating point representation. @@ -167,22 +188,30 @@ impl Context { return Ok(FBig::::ONE.with_precision(self.precision)); } - let (work_context, r, quadrant) = self.reduce_to_quadrant(x, reborrow_cache(&mut cache)); - - // 3. Evaluate the reduced series based on the quadrant - let res = match quadrant { - Quadrant::First => work_context.cos_internal(&r), - Quadrant::Second => -work_context.sin_internal(&r), - Quadrant::Third => -work_context.cos_internal(&r), - Quadrant::Fourth => work_context.sin_internal(&r), - }; - Ok(res.with_precision(self.precision)) + Ok(self.ziv(50, |guard| { + let (work, r, quadrant, reduction_err) = + self.reduce_to_quadrant(x, guard, reborrow_cache(&mut cache)); + let (val, series_radius) = match quadrant { + Quadrant::First => work.cos_compute(&r), + Quadrant::Second => { + let (v, e) = work.sin_compute(&r); + (-v, e) + } + Quadrant::Third => { + let (v, e) = work.cos_compute(&r); + (-v, e) + } + Quadrant::Fourth => work.sin_compute(&r), + }; + (val, series_radius + reduction_err) + })) } - /// Internal Taylor series for cosine: C(x) = 1 - x^2/2! + x^4/4! - ... - fn cos_internal(self, x: &FBig) -> FBig { + /// Near-correct cosine series `C(x) = 1 − x²/2! + x⁴/4! − …`, returning `(value, radius)`. + /// (See [`sin_compute`](Self::sin_compute) for the radius derivation.) + fn cos_compute(self, x: &FBig) -> (FBig, FBig) { if x.repr.significand.is_zero() { - return FBig::ONE.with_precision(self.precision).value(); + return (FBig::ONE.with_precision(self.precision).value(), FBig::ZERO); } let x2 = x.sqr(); let mut sum = FBig::::ONE.with_precision(self.precision).value(); @@ -202,7 +231,8 @@ impl Context { } k += 1; } - sum + let radius = sum.ulp() * (4 * k + 12); + (sum, radius) } /// Calculate both the sine and cosine of the floating point representation. @@ -225,27 +255,28 @@ impl Context { return (s, c); } - let (work_context, r, quadrant) = self.reduce_to_quadrant(x, reborrow_cache(&mut cache)); - - let (sin_r, cos_r) = work_context.sin_cos_internal(&r); - - let (s, c) = match quadrant { - Quadrant::First => (sin_r, cos_r), - Quadrant::Second => (cos_r, -sin_r), - Quadrant::Third => (-sin_r, -cos_r), - Quadrant::Fourth => (-cos_r, sin_r), - }; - - (Ok(s.with_precision(self.precision)), Ok(c.with_precision(self.precision))) + let (s, c) = self.ziv_pair(50, |guard| { + let (work, r, quadrant, reduction_err) = + self.reduce_to_quadrant(x, guard, reborrow_cache(&mut cache)); + let ((sin_r, sin_e), (cos_r, cos_e)) = work.sin_cos_compute(&r); + let (s, c) = match quadrant { + Quadrant::First => (sin_r, cos_r), + Quadrant::Second => (cos_r, -sin_r), + Quadrant::Third => (-sin_r, -cos_r), + Quadrant::Fourth => (-cos_r, sin_r), + }; + ((s, sin_e + reduction_err.clone()), (c, cos_e + reduction_err)) + }); + (Ok(s), Ok(c)) } - /// Simultaneously evaluate Taylor series for sine and cosine. - pub(crate) fn sin_cos_internal( - self, - x: &FBig, - ) -> (FBig, FBig) { + /// Simultaneously evaluate the sine and cosine series, returning both values and their radii. + pub(crate) fn sin_cos_compute(self, x: &FBig) -> (Rad, Rad) { if x.repr.significand.is_zero() { - return (FBig::ZERO, FBig::ONE.with_precision(self.precision).value()); + return ( + (FBig::ZERO, FBig::ZERO), + (FBig::ONE.with_precision(self.precision).value(), FBig::ZERO), + ); } let x2 = x.sqr(); let mut sin_sum = x.clone(); @@ -275,7 +306,10 @@ impl Context { } k += 1; } - (sin_sum, cos_sum) + ( + (sin_sum.clone(), sin_sum.ulp() * (4 * k + 12)), + (cos_sum.clone(), cos_sum.ulp() * (4 * k + 12)), + ) } /// Calculate the tangent of the floating point representation. @@ -297,27 +331,49 @@ impl Context { return signed_zero_normal(self, x); } - let (work_context, r, quadrant) = self.reduce_to_quadrant(x, reborrow_cache(&mut cache)); - let (sin_r, cos_r) = work_context.sin_cos_internal(&r); - - let (s_f, c_f) = match quadrant { - Quadrant::First => (sin_r, cos_r), - Quadrant::Second => (cos_r, -sin_r), - Quadrant::Third => (-sin_r, -cos_r), - Quadrant::Fourth => (-cos_r, sin_r), + // Pole check (hoisted — the Ziv closure can't return ±∞): if cos rounds to +0, x is at a tan + // pole → ±∞ (sign of the numerator). + let (work_p, r_p, quad_p, _) = self.reduce_to_quadrant(x, 50, reborrow_cache(&mut cache)); + let ((sin_p, _), (cos_p, _)) = work_p.sin_cos_compute(&r_p); + let (s_p, c_p) = match quad_p { + Quadrant::First => (sin_p, cos_p), + Quadrant::Second => (cos_p, -sin_p), + Quadrant::Third => (-sin_p, -cos_p), + Quadrant::Fourth => (-cos_p, sin_p), }; - - if c_f.repr.is_pos_zero() { - // tan hits a pole: the result is an infinity with the sign of the numerator. - let inf = if s_f.sign() == Sign::Negative { + if c_p.repr.is_pos_zero() { + let inf = if s_p.sign() == Sign::Negative { Repr::neg_infinity() } else { Repr::infinity() }; return Ok(Rounded::Exact(FBig::new(inf, *self))); } - self.div(&s_f.repr, &c_f.repr) - .map(|r| r.and_then(|f| f.with_precision(self.precision))) + + Ok(self.ziv(50, |guard| { + let (work, r, quadrant, reduction_err) = + self.reduce_to_quadrant(x, guard, reborrow_cache(&mut cache)); + let ((sin_r, sin_e), (cos_r, cos_e)) = work.sin_cos_compute(&r); + let (s, c) = match quadrant { + Quadrant::First => (sin_r, cos_r), + Quadrant::Second => (cos_r, -sin_r), + Quadrant::Third => (-sin_r, -cos_r), + Quadrant::Fourth => (-cos_r, sin_r), + }; + if c.repr.is_pos_zero() { + // cos rounded to +0 at a higher guard (a deep near-pole): force a retry. The exact + // pole is unreachable for rational x, so a higher guard makes cos representable. + return (FBig::ZERO, FBig::ONE); + } + let result = work.div(&s.repr, &c.repr).unwrap().value(); + // tan = s/c: the sin/cos radii propagate as (e_s + |tan|·e_c)/|c| plus the division + // rounding, all at the working precision (the only term that needed unlimited precision + // — the reduction error — is already work-precision, so the `/|c|` stays legal). + let e_s = sin_e + reduction_err.clone(); + let e_c = cos_e + reduction_err; + let radius = (e_s + result.clone().abs() * e_c) / c.clone().abs() + result.ulp() * 8; + (result, radius) + })) } /// Calculate the arcsine of the floating point representation. @@ -341,39 +397,37 @@ impl Context { return Err(FpError::OutOfDomain); } - let guard_digits = 50; - let work_precision = self.precision + guard_digits; - let work_context = Self::new(work_precision); - - let x_f = FBig::::new(work_context.repr_round(x.clone()).value(), work_context); - - let res = work_context.asin_internal(&x_f, reborrow_cache(&mut cache)); - Ok(res.with_precision(self.precision)) - } - - fn asin_internal( - self, - x_f: &FBig, - mut cache: Option<&mut ConstCache>, - ) -> FBig { - let one = FBig::::ONE.with_precision(self.precision).value(); - let x2 = x_f.sqr(); - let d = self.unwrap_fp(self.sqrt(&(one - x2).repr)); - - if d.repr.is_pos_zero() || d.repr.is_neg_zero() { - // |x| = 1 exactly (d = sqrt(1 - x²) = ±0); asin(±1) = ±π/2 regardless of rounding - // mode. Catch `-0` too: under roundTowardNegative `1 - 1` cancels to `-0`, sqrt(-0) = - // `-0`, and the general path would divide by `-0` → `-∞` and panic. (For |x| < 1, - // `d` is strictly positive, so only the exact-boundary x reaches this branch.) - let pi = self.pi::(reborrow_cache(&mut cache)).value(); - let half_pi: FBig = pi / 2; - if x_f.sign() == Sign::Positive { - return half_pi; + Ok(self.ziv(50, |guard| { + let work = Context::::new(self.precision + guard); + let x_f = FBig::::new(work.repr_round_ref(x).value(), work); + let one = FBig::::ONE.with_precision(work.precision).value(); + let d = work + .sqrt(&(one.clone() - x_f.clone().sqr()).repr) + .unwrap() + .value(); + if d.repr.is_pos_zero() || d.repr.is_neg_zero() { + // |x| = 1: asin(±1) = ±π/2. + let pi = work.pi::(reborrow_cache(&mut cache)).value(); + let half_pi = pi / 2u8; + let res = if x_f.sign() == Sign::Positive { + half_pi + } else { + -half_pi + }; + let radius = res.ulp() * 4; + return (res, radius); } - return -half_pi; - } - - self.atan_with_reduction(&(x_f / d), reborrow_cache(&mut cache)) + // asin(x) = atan(x / sqrt(1−x²)); `atan`/`sqrt` are Ziv-correct at the working + // precision, so the radius is just the accumulated `sqrt`+`div` rounding (well-conditioned + // near |x|=1, where atan's derivative → 0). + let arg = &x_f / &d; + let res = work + .atan(&arg.repr, reborrow_cache(&mut cache)) + .unwrap() + .value(); + let radius = res.ulp() * 16; + (res, radius) + })) } /// Calculate the arccosine of the floating point representation. @@ -397,17 +451,18 @@ impl Context { return Err(FpError::OutOfDomain); } - let guard_digits = 50; - let work_precision = self.precision + guard_digits; - let work_context = Self::new(work_precision); - - let x_f = FBig::::new(work_context.repr_round(x.clone()).value(), work_context); - - let asin_x = work_context.asin_internal(&x_f, reborrow_cache(&mut cache)); - let pi = work_context.pi::(reborrow_cache(&mut cache)).value(); - let half_pi: FBig = pi / 2; - let res: FBig = half_pi - asin_x; - Ok(res.with_precision(self.precision)) + Ok(self.ziv(50, |guard| { + let work = Context::::new(self.precision + guard); + // acos(x) = π/2 − asin(x); `asin`/`pi` are Ziv-correct (or exact) at the working + // precision. The radius covers the propagated asin/π rounding plus the subtraction, + // which cancels near x = 1 — the radius grows there and Ziv retries with more guard. + let asin_x = work.asin(x, reborrow_cache(&mut cache)).unwrap().value(); + let pi = work.pi::(reborrow_cache(&mut cache)).value(); + let res = (pi / 2u8) - &asin_x; + let radius = asin_x.ulp().clone().with_precision(0).value() * 2 + + res.ulp().clone().with_precision(0).value() * 4; + (res, radius) + })) } /// Calculate the arctangent of the floating point representation. @@ -435,42 +490,31 @@ impl Context { return signed_zero_normal(self, x); } - let guard_digits = 50; - let work_precision = self.precision + guard_digits; - let work_context = Self::new(work_precision); - - let x_f = FBig::::new(work_context.repr_round(x.clone()).value(), work_context); - let res = work_context.atan_with_reduction(&x_f, reborrow_cache(&mut cache)); - Ok(res.with_precision(self.precision)) - } - - /// Internal arctangent that includes range reduction but no guard digit allocation. - fn atan_with_reduction( - self, - x_f: &FBig, - mut cache: Option<&mut ConstCache>, - ) -> FBig { - let sign = x_f.sign(); - let mut x_abs = x_f.clone(); - if sign == Sign::Negative { - x_abs = -x_abs; - } - let mut res = if x_abs >= FBig::::ONE.with_precision(self.precision).value() { - let pi = self.pi::(reborrow_cache(&mut cache)).value(); - let inv_x = FBig::::ONE.with_precision(self.precision).value() / x_abs; - (pi / 2) - self.atan_internal(&inv_x) - } else { - self.atan_internal(&x_abs) - }; - if sign == Sign::Negative { - res = -res; - } - res + Ok(self.ziv(50, |guard| { + let work = Context::::new(self.precision + guard); + let x_f = FBig::::new(work.repr_round_ref(x).value(), work); + let sign = x_f.sign(); + let x_abs = x_f.abs(); + let one = FBig::::ONE.with_precision(work.precision).value(); + let (res, radius) = if x_abs >= one { + // |x| ≥ 1: atan(x) = π/2 − atan(1/x); the series runs on 1/x ∈ (0, 1]. + let pi = work.pi::(reborrow_cache(&mut cache)).value(); + let inv_x = &one / &x_abs; + let (atan_val, atan_radius) = work.atan_compute(&inv_x); + let res = (pi / 2u8) - atan_val; + let radius = atan_radius + res.ulp() * 4; + (res, radius) + } else { + work.atan_compute(&x_abs) + }; + let res = if sign == Sign::Negative { -res } else { res }; + (res, radius) + })) } - /// Internal series for arctangent. - /// Evaluates the Euler series for arctangent. - fn atan_internal(self, x: &FBig) -> FBig { + /// Near-correct Euler series for `atan(x)` (`|x| ≤ 1`), returning `(value, radius)`. The radius + /// covers series truncation plus `~3N` steps of accumulation. + fn atan_compute(self, x: &FBig) -> (FBig, FBig) { // Euler's series for atan(x) let x2 = x.sqr(); let one_plus_x2 = FBig::ONE + &x2; @@ -489,7 +533,8 @@ impl Context { sum += &term; n += 1; } - sum + let radius = sum.ulp() * (4 * n + 12); + (sum, radius) } /// Calculate the arctangent of y / x. @@ -508,96 +553,72 @@ impl Context { assert_limited_precision(self.precision); - let guard_digits = 50; - let work_precision = self.precision + guard_digits; - let work_context = Self::new(work_precision); - - // Handle Infinities according to IEEE 754 + // Handle Infinities according to IEEE 754 (computed at the target precision). if y.is_infinite() || x.is_infinite() { let (sy, sx) = (y.sign() == Sign::Positive, x.sign() == Sign::Positive); + let pi_val = self.pi::(reborrow_cache(&mut cache)).value(); let res: FBig = match (y.is_infinite(), x.is_infinite(), sy, sx) { - (true, true, true, true) => { - work_context.pi::(reborrow_cache(&mut cache)).value() / 4 - } - (true, true, true, false) => { - work_context.pi::(reborrow_cache(&mut cache)).value() * 3 / 4 - } - (true, true, false, true) => { - let pi4: FBig = - work_context.pi::(reborrow_cache(&mut cache)).value() / 4; - -pi4 - } - (true, true, false, false) => { - let pi34: FBig = - work_context.pi::(reborrow_cache(&mut cache)).value() * 3 / 4; - -pi34 - } - (true, false, true, _) => { - work_context.pi::(reborrow_cache(&mut cache)).value() / 2 - } - (true, false, false, _) => { - let half_pi: FBig = - work_context.pi::(reborrow_cache(&mut cache)).value() / 2; - -half_pi - } + (true, true, true, true) => pi_val.clone() / 4u8, + (true, true, true, false) => pi_val.clone() * 3u8 / 4u8, + (true, true, false, true) => -(pi_val.clone() / 4u8), + (true, true, false, false) => -(pi_val.clone() * 3u8 / 4u8), + (true, false, true, _) => pi_val.clone() / 2u8, + (true, false, false, _) => -(pi_val.clone() / 2u8), (false, true, _, true) => { // atan2(±finite, +inf) = ±0 (signed zero of y) if sy { - FBig::::ZERO.with_precision(work_precision).value() + FBig::::ZERO } else { - FBig::::new(Repr::neg_zero(), work_context) - .with_precision(work_precision) - .value() + FBig::::new(Repr::neg_zero(), *self) } } - (false, true, true, false) => { - work_context.pi::(reborrow_cache(&mut cache)).value() - } - (false, true, false, false) => { - -work_context.pi::(reborrow_cache(&mut cache)).value() - } + (false, true, true, false) => pi_val.clone(), + (false, true, false, false) => -pi_val, _ => unreachable!(), }; return Ok(res.with_precision(self.precision)); } - let y_f = FBig::::new(work_context.repr_round(y.clone()).value(), work_context); - let x_f = FBig::::new(work_context.repr_round(x.clone()).value(), work_context); + // x == 0, y finite nonzero: atan2 = ±π/2. + if x.significand.is_zero() { + let half_pi = self.pi::(reborrow_cache(&mut cache)).value() / 2u8; + let res = if y.sign() == Sign::Positive { + half_pi + } else { + -half_pi + }; + return Ok(res.with_precision(self.precision)); + } - match x_f.cmp(&FBig::::ZERO) { - Ordering::Greater => { - let res = - work_context.atan_with_reduction(&(y_f / x_f), reborrow_cache(&mut cache)); - Ok(res.with_precision(self.precision)) - } - Ordering::Less => { - let pi = work_context.pi::(reborrow_cache(&mut cache)).value(); - let y_sign = y_f.sign(); - let atan_yx = - work_context.atan_with_reduction(&(y_f / x_f), reborrow_cache(&mut cache)); - let res = if y_sign == Sign::Positive { - atan_yx + pi + // x ≠ 0, finite: atan2 = atan(y/x) ± (quadrant π). `atan` is Ziv-correct at the working + // precision, so the radius is the accumulated div/π-arithmetic rounding. + Ok(self.ziv(50, |guard| { + let work = Context::::new(self.precision + guard); + let y_f = FBig::::new(work.repr_round_ref(y).value(), work); + let x_f = FBig::::new(work.repr_round_ref(x).value(), work); + let ratio = &y_f / &x_f; + let atan_val = work + .atan(&ratio.repr, reborrow_cache(&mut cache)) + .unwrap() + .value(); + let (res, radius) = if x.sign() == Sign::Positive { + (atan_val.clone(), atan_val.ulp() * 6) + } else { + let pi = work.pi::(reborrow_cache(&mut cache)).value(); + let r = if y_f.sign() == Sign::Positive { + &atan_val + &pi } else { - atan_yx - pi + &atan_val - &pi }; - Ok(res.with_precision(self.precision)) - } - Ordering::Equal => { - // x == 0 case - let pi = work_context.pi::(reborrow_cache(&mut cache)).value(); - let half_pi: FBig = pi / 2; - if y_f > FBig::::ZERO { - Ok(half_pi.with_precision(self.precision)) - } else { - let res = -half_pi; - Ok(res.with_precision(self.precision)) - } - } - } + let radius = atan_val.ulp() * 2 + r.ulp() * 6; + (r, radius) + }; + (res, radius) + })) } } -impl FBig { +impl FBig { /// Calculate the sine of the floating point number. /// /// # Panics diff --git a/float/src/root.rs b/float/src/root.rs index 5e806285..8279dcfe 100644 --- a/float/src/root.rs +++ b/float/src/root.rs @@ -1,11 +1,13 @@ -use dashu_base::{Approximation, CubicRoot, Sign, SquareRoot, SquareRootRem, UnsignedAbs}; +use dashu_base::{ + Approximation, CubicRoot, EstimatedLog2, Sign, SquareRoot, SquareRootRem, UnsignedAbs, +}; use dashu_int::{IBig, UBig}; use crate::{ error::{assert_limited_precision, panic_root_zeroth, FpError, FpResult}, fbig::FBig, repr::{Context, Repr, Word}, - round::Round, + round::{ErrorBounds, Round}, utils::{shl_digits, split_digits_ref}, }; @@ -254,14 +256,13 @@ impl Context { } } -impl Context { +impl Context { /// Compute `sqrt(a² + b²)` without spurious overflow/underflow. /// /// This is the overflow-safe scaled sum-of-squares: the larger-magnitude operand is never /// squared. Writing `m = max(|a|, |b|)` and `r = min(|a|,|b|) / m` (so `|r| ≤ 1`), the result is - /// `m · sqrt(1 + r²)`, where `1 + r² ∈ [1, 2]` cannot overflow. The final `m · sqrt(1 + r²)` - /// overflows only when the true result genuinely exceeds the exponent range (reported as - /// [`FpError::Overflow`]). `hypot(±inf, ·) = +inf`, `hypot(0, 0) = +0`. + /// `m · sqrt(1 + r²)`, where `1 + r² ∈ [1, 2]` cannot overflow. The result is correctly rounded + /// via a Ziv retry loop (`hypot(±inf, ·) = +inf`, `hypot(0, 0) = +0`). /// /// This is a field-arithmetic-class op (no constant cache), like `sqrt`/`atan2`. /// @@ -277,11 +278,6 @@ impl Context { return Ok(Approximation::Exact(FBig::new(Repr::zero(), *self))); } - let guard = crate::utils::ceil_usize(::log2_est( - &self.precision, - )) + 10; - let gctx = Context::::new(self.precision + guard); - // magnitudes, ordered large >= small (both finite, not both zero here) let a_mag = if a.sign() == Sign::Negative { -a.clone() @@ -300,21 +296,36 @@ impl Context { }; if small.significand.is_zero() { - // hypot(x, 0) = |x|; `large` is already a magnitude - return Ok(gctx.repr_round_ref(&large).map(|v| FBig::new(v, *self))); + // hypot(x, 0) = |x|; `large` is already a magnitude. + return Ok(self.repr_round_ref(&large).map(|v| FBig::new(v, *self))); } - // r = small / large ∈ [0, 1]; 1 + r² ∈ [1, 2] (no overflow); result = large · sqrt(1+r²) - let r = gctx.div(&small, &large)?.value(); - let r2 = gctx.sqr(r.repr())?.value(); - let sum = gctx.add(&Repr::one(), r2.repr())?.value(); - let root = gctx.sqrt(sum.repr())?.value(); - let result = gctx.mul(&large, root.repr())?.value(); - Ok(result.with_precision(self.precision)) + // The result is `large · sqrt(1 + (small/large)²)`, i.e. ∈ [large, large·√2]. It overflows + // only when `large` is so large that this product reaches the infinity sentinel exponent — + // unreachable for real inputs, but pre-checked here so the Ziv closure can use infallible + // `FBig` arithmetic. + if large.exponent >= isize::MAX - 1 { + return Err(FpError::Overflow(Sign::Positive)); + } + + let initial_guard = crate::utils::ceil_usize(self.precision.log2_est()) + 10; + Ok(self.ziv(initial_guard, |guard| { + let gctx = Context::::new(self.precision + guard); + let large_f = FBig::new(gctx.repr_round_ref(&large).value(), gctx); + let small_f = FBig::new(gctx.repr_round_ref(&small).value(), gctx); + // r = small/large ∈ [0,1]; 1 + r² ∈ [1,2]; result = large · sqrt(1 + r²). Every op is + // correctly rounded at the working precision; the radius is a small multiple of the + // result's working ULP (the sqrt attenuates, no condition amplification). + let r = &small_f / &large_f; + let root = (FBig::::ONE + r.sqr()).sqrt(); + let result = &large_f * &root; + let radius = result.ulp() * 8; + (result, radius) + })) } } -impl FBig { +impl FBig { /// Compute `sqrt(self² + other²)` without spurious overflow/underflow. /// /// The result precision is `max(self.precision(), other.precision())`. See diff --git a/float/src/ziv.rs b/float/src/ziv.rs index d3528126..b6aa3393 100644 --- a/float/src/ziv.rs +++ b/float/src/ziv.rs @@ -93,6 +93,49 @@ impl Context { last.expect("MAX_ZIV_RETRIES is non-zero") } + /// Pair variant of [`ziv`](Self::ziv) for functions that return two values (e.g. `sin_cos`, + /// `sinh_cosh`). `approx(guard)` returns `((v1, e1), (v2, e2))` — both values and their + /// provable radii at the working context, sharing whatever computation is common. The driver + /// certifies **both** values: it retries while *either* containment test fails, and returns + /// both only when both fit their rounding preimages. Shares the guard-growth loop and retry + /// counter with [`ziv`](Self::ziv). + pub(crate) fn ziv_pair( + &self, + initial_guard: usize, + mut approx: impl FnMut(usize) -> ((FBig, FBig), (FBig, FBig)), + ) -> (Rounded>, Rounded>) { + // Unlimited precision: both approximations are exact, report them as-is. + if !self.is_limited() { + let ((v1, _), (v2, _)) = approx(0); + return (Exact(v1), Exact(v2)); + } + + let mut guard = initial_guard; + let mut last = None; + #[cfg(test)] + LAST_ZIV_RETRIES.with(|c| c.set(0)); + for _ in 0..MAX_ZIV_RETRIES { + let ((a1, e1), (a2, e2)) = approx(guard); + let c1 = a1.clone().with_precision(self.precision); + let c2 = a2.clone().with_precision(self.precision); + if Self::contained::(&a1.repr, &e1.repr, c1.value_ref()) + && Self::contained::(&a2.repr, &e2.repr, c2.value_ref()) + { + return (c1, c2); + } + last = Some((c1, c2)); + + // Grow the guard aggressively so a near-tie resolves in a couple of retries. + let step = core::cmp::max(guard, self.precision / 2).max(1); + guard += step; + #[cfg(test)] + LAST_ZIV_RETRIES.with(|c| c.set(c.get() + 1)); + } + + // Unreachable in practice: return the best-effort pair from the last attempt. + last.expect("MAX_ZIV_RETRIES is non-zero") + } + /// Containment test: is the approximation's error interval `[a − e, a + e]` entirely inside /// the rounding preimage of `y` (every real in `[y − lb, y + rb]` rounds to `y` under `R`)? /// @@ -171,6 +214,30 @@ mod tests { assert_eq!(LAST_ZIV_RETRIES.with(|c| c.get()), usize::MAX); } + // ziv_pair accepts an exact pair (both radii 0) on the first attempt. + #[test] + fn ziv_pair_accepts_exact_first_attempt() { + let ctx: Context = Context::new(10); + LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX)); + let (r1, r2) = ctx.ziv_pair(4, |_| ((F::ONE, F::ZERO), (F::from(2u8), F::ZERO))); + assert!(matches!(r1, Exact(_))); + assert!(matches!(r2, Exact(_))); + assert_eq!(LAST_ZIV_RETRIES.with(|c| c.get()), 0); + } + + // ziv_pair retries while *either* value's interval straddles a boundary; here the second value + // carries the shrinking radius, so the pair must retry together. + #[test] + fn ziv_pair_retries_until_both_contained() { + let ctx: Context = Context::new(4); + let (r1, r2) = ctx.ziv_pair(2, |guard| { + let radius = F::ONE >> guard as isize; + ((F::ONE, F::ZERO), (F::ONE, radius)) + }); + let _ = (r1.value(), r2.value()); + assert!(LAST_ZIV_RETRIES.with(|c| c.get()) >= 1); + } + // The guard-digit heuristic should let exp/ln converge in at most one retry for typical // inputs. A single retry on a near-tie is by design (Ziv certifies correctness; the guard only // controls the first-attempt hit rate). This catches gross guard mis-sizing (many retries), diff --git a/float/tests/exp_log_root_prop.rs b/float/tests/exp_log_root_prop.rs index 6cc32ed4..b4f52ade 100644 --- a/float/tests/exp_log_root_prop.rs +++ b/float/tests/exp_log_root_prop.rs @@ -123,4 +123,157 @@ proptest! { let r_2p = x_2p.ln_1p().with_precision(P).value(); prop_assert_eq!(r_p, r_2p); } + + /// Correct-rounding self-oracle for hypot. + #[test] + fn hypot_rounding_exact_oracle(a in signed_x(100_000), b in signed_x(100_000)) { + let r_p = a.clone().hypot(&b); + let a_2p = a.clone().with_precision(2 * P).value(); + let b_2p = b.clone().with_precision(2 * P).value(); + let r_2p = a_2p.hypot(&b_2p).with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for sinh. + #[test] + fn sinh_rounding_exact_oracle(x in signed_x(2_000)) { + let r_p = x.sinh(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.sinh().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for cosh. + #[test] + fn cosh_rounding_exact_oracle(x in signed_x(2_000)) { + let r_p = x.cosh(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.cosh().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for tanh (saturates toward ±1, so a wide range is safe). + #[test] + fn tanh_rounding_exact_oracle(x in signed_x(200_000)) { + let r_p = x.tanh(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.tanh().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for sinh_cosh (both components). + #[test] + fn sinh_cosh_rounding_exact_oracle(x in signed_x(2_000)) { + let (s_p, c_p) = x.sinh_cosh(); + let x_2p = x.clone().with_precision(2 * P).value(); + let (s_2p, c_2p) = x_2p.sinh_cosh(); + prop_assert_eq!(s_p, s_2p.with_precision(P).value()); + prop_assert_eq!(c_p, c_2p.with_precision(P).value()); + } + + /// Correct-rounding self-oracle for asinh. + #[test] + fn asinh_rounding_exact_oracle(x in signed_x(200_000)) { + let r_p = x.asinh(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.asinh().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for acosh (domain x ≥ 1; m·10⁻⁴ with m ≥ 10001 keeps x > 1). + #[test] + fn acosh_rounding_exact_oracle(m in 10001i64..200_000i64) { + let x = x_from(m); + let r_p = x.acosh(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.acosh().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for atanh (domain |x| < 1; m·10⁻⁴ with |m| ≤ 9999). + #[test] + fn atanh_rounding_exact_oracle(m in -9999i64..9999i64) { + let x = x_from(m); + let r_p = x.atanh(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.atanh().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for sin (exercises the π/2 argument reduction for |x| > ~1.5). + #[test] + fn sin_rounding_exact_oracle(x in signed_x(200_000)) { + let r_p = x.sin(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.sin().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for cos. + #[test] + fn cos_rounding_exact_oracle(x in signed_x(200_000)) { + let r_p = x.cos(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.cos().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for sin_cos (both components). + #[test] + fn sin_cos_rounding_exact_oracle(x in signed_x(200_000)) { + let (s_p, c_p) = x.sin_cos(); + let x_2p = x.clone().with_precision(2 * P).value(); + let (s_2p, c_2p) = x_2p.sin_cos(); + prop_assert_eq!(s_p, s_2p.with_precision(P).value()); + prop_assert_eq!(c_p, c_2p.with_precision(P).value()); + } + + /// Correct-rounding self-oracle for tan (skips exact poles — unreachable for rational x, but + /// near-poles yield huge finite values that round identically). + #[test] + fn tan_rounding_exact_oracle(x in signed_x(200_000)) { + let r_p = x.tan(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.tan().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for atan. + #[test] + fn atan_rounding_exact_oracle(x in signed_x(200_000)) { + let r_p = x.atan(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.atan().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for asin (domain |x| ≤ 1; m·10⁻⁴ with |m| ≤ 10000). + #[test] + fn asin_rounding_exact_oracle(m in -10000i64..10000i64) { + let x = x_from(m); + let r_p = x.asin(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.asin().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for acos (domain |x| ≤ 1). + #[test] + fn acos_rounding_exact_oracle(m in -10000i64..10000i64) { + let x = x_from(m); + let r_p = x.acos(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = x_2p.acos().with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } + + /// Correct-rounding self-oracle for atan2. + #[test] + fn atan2_rounding_exact_oracle(y in signed_x(200_000), x in signed_x(200_000)) { + let r_p = y.clone().atan2(&x); + let y_2p = y.clone().with_precision(2 * P).value(); + let x_2p = x.clone().with_precision(2 * P).value(); + let r_2p = y_2p.atan2(&x_2p).with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } } diff --git a/guide-zh/src/compliance.md b/guide-zh/src/compliance.md index f5e11a34..91b4734a 100644 --- a/guide-zh/src/compliance.md +++ b/guide-zh/src/compliance.md @@ -51,7 +51,7 @@ | IEEE 754 要求 | 合规性 | 说明 | |---------------------|-----------|-------| | 舍入模式:roundTiesToEven、roundTiesToAway、roundTowardPositive、roundTowardNegative、roundTowardZero | ✅ | 全部五种模式实现为 `HalfEven`、`HalfAway`、`Up`、`Down`、`Zero`。 | -| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及基本超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`)通过 Ziv 循环保证正确舍入;其余超越函数通过保护位策略在 1 ulp 以内。`Rounded` 类型区分精确与非精确结果。 | +| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及实超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`)通过 Ziv 循环保证正确舍入;`powf` 与 `dashu-cmplx` 的复数超越函数*包装层*仍在 1 ulp 以内(经如今正确舍入的实原语的保护位组合计算)。`Rounded` 类型区分精确与非精确结果。 | | 就近舍入保持零的符号 | ✅ | `rounded_to_repr` 在舍入将非零值折叠为零时保持输入符号。 | #### 第 5.6 节——符号位运算 diff --git a/guide-zh/src/faq.md b/guide-zh/src/faq.md index 1c620921..26936618 100644 --- a/guide-zh/src/faq.md +++ b/guide-zh/src/faq.md @@ -26,7 +26,7 @@ ## 已知限制 - **没有 NaN。** 无效运算在便捷层会 panic,在上下文层返回 `Err(FpError)`。无穷是终态值,不是操作数——参见[标准合规性](./compliance.md)。 -- **近似正确舍入。** 基本超越函数 `exp`、`exp_m1`、`ln`、`ln_1p` 通过 Ziv 重试循环保证正确舍入。其余超越函数(三角、双曲、`powf`、复数)仍通过保护位策略在 1 ulp 以内舍入。 +- **正确舍入的超越函数。** 实超越函数——`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`——均通过 Ziv 重试循环保证正确舍入。`powf` 与 `dashu-cmplx` 的复数超越函数*包装层*仍为近似正确(1 ulp 以内),但其内部已调用如今正确舍入的实原语。 - **复数功能覆盖。** `CBig` 提供域运算和基本超越函数;复数双曲函数、`fma` 及其他若干功能推迟到 0.5.x(参见 v0.5 版本说明)。 - **没有球体/区间运算。** 与 MPC 的实验性 `mpcb_t`(复数球体)不同,`dashu` 不提供区间或球体类型。这是一个刻意的范围选择:球体运算在上游仍是实验性的,如果/当它稳定后,最好由一个**独立的 crate** 在 `dashu-float`/`dashu-cmplx` 之上提供,而非耦合到核心类型。 - **尚无 SIMD-FFT 乘法**(计划在 v1.0 中实现)。 diff --git a/guide/src/compliance.md b/guide/src/compliance.md index 1c1474ee..759ae57a 100644 --- a/guide/src/compliance.md +++ b/guide/src/compliance.md @@ -56,7 +56,7 @@ The reference here is IEEE Std 754™-2008 (ISO/IEC/IEEE 60559:2011). | IEEE 754 requirement | Compliance | Notes | |---------------------|-----------|-------| | Rounding modes: roundTiesToEven, roundTiesToAway, roundTowardPositive, roundTowardNegative, roundTowardZero | ✅ | All five modes implemented as `HalfEven`, `HalfAway`, `Up`, `Down`, `Zero`. | -| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the elementary transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`) are guaranteed-correctly rounded via a Ziv loop; the remaining transcendentals are within 1 ulp via a guard-digit recipe. The `Rounded` type distinguishes exact from inexact results. | +| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the real transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`) are guaranteed-correctly rounded via a Ziv loop; `powf` and `dashu-cmplx`'s complex transcendental *wrappers* are within 1 ulp (guard-digit composition routing through the now-correct real primitives). The `Rounded` type distinguishes exact from inexact results. | | Round-to-nearest preserves sign of zero | ✅ | `rounded_to_repr` preserves input sign when rounding collapses a non-zero to zero. | #### Section 5.6 — Sign bit operations diff --git a/guide/src/faq.md b/guide/src/faq.md index 0cd898a3..ccb0fa32 100644 --- a/guide/src/faq.md +++ b/guide/src/faq.md @@ -29,7 +29,7 @@ Compared with other Rust crates: ## Known limitations - **No NaN.** Invalid operations panic at the convenience layer and return `Err(FpError)` at the context layer. Infinities are terminal values, not operands — see [Standards Compliance](./compliance.md). -- **Near-correct rounding.** The elementary transcendentals `exp`, `exp_m1`, `ln`, `ln_1p` are guaranteed-correctly rounded via a Ziv retry loop. The remaining transcendentals (trig, hyperbolic, `powf`, complex) are still rounded within 1 ulp via a guard-digit recipe. +- **Correctly-rounded transcendentals.** The real transcendentals — `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot` — are guaranteed-correctly rounded via a Ziv retry loop. `powf` and `dashu-cmplx`'s complex transcendental *wrappers* are still near-correct (within 1 ulp), routing through the now-correct real primitives. - **Complex surface.** `CBig` ships field arithmetic and the elementary transcendentals; complex hyperbolics, `fma`, and several others are deferred to 0.5.x (see the v0.5 release notes). - **No ball/interval arithmetic.** Unlike MPC's experimental `mpcb_t` (complex balls), `dashu` does not provide interval or ball types. This is a deliberate scope choice: ball arithmetic is From 856df5df609b06237aee0e1253d5b79081ecb8d4 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Mon, 20 Jul 2026 14:45:30 +0800 Subject: [PATCH 05/21] Ziv loop for powf (non-integer exponent); integer exponent delegates to powi powf with a non-integer exponent is now correctly rounded via the Ziv loop. The fix for the previously-deferred amplification problem: the error radius is result.ulp()*(|y*ln x|+1)*(B+8) taken at the *working* precision, so it shrinks as B^{-guard} and the containment test converges. A radius computed at unlimited precision is constant across retries and never settles for a value near a rounding boundary, which is why the earlier attempt infinite-retried. An integer-valued exponent now delegates to powi (binary exponentiation), gated on Repr::is_int. This also admits a negative base (sign fixed by the exponent's parity) -- the old OutOfDomain TODO. That path is near-correct (<=1 ulp), matching powi. The exp overflow case is hoisted out of the Ziv closure. Also: Repr::is_int is now const (pure exponent/sentinel check). Co-Authored-By: Claude --- V1-ROADMAP.md | 22 ++++--- float/CHANGELOG.md | 13 +++- float/src/exp.rs | 103 +++++++++++++++++++++++++------ float/src/repr.rs | 2 +- float/tests/exp_log_root_prop.rs | 16 +++++ guide-zh/src/compliance.md | 2 +- guide-zh/src/faq.md | 2 +- guide/src/compliance.md | 2 +- guide/src/faq.md | 2 +- 9 files changed, 129 insertions(+), 35 deletions(-) diff --git a/V1-ROADMAP.md b/V1-ROADMAP.md index 2a4e4709..33f094d2 100644 --- a/V1-ROADMAP.md +++ b/V1-ROADMAP.md @@ -1,6 +1,6 @@ # dashu Roadmap — v0.5.x and beyond -Last updated: 2026-07-11 +Last updated: 2026-07-20 Feature work deferred out of the **v0.5.0** release. The v0.5.x items are all **additive** (no breaking changes) and safe to ship as point releases on top of 0.5.0; the post-v1 items @@ -33,15 +33,19 @@ are longer-term goals. File:line references are anchors from the v0.5.0 tree and ### Correctness -- **Guaranteed-correct rounding (Ziv retry loop).** ✅ *Delivered for all real transcendentals - except `powf`.* `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric family (`sin`, `cos`, `sin_cos`, +- **Guaranteed-correct rounding (Ziv retry loop).** ✅ *Delivered for all real transcendentals.* + `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric family (`sin`, `cos`, `sin_cos`, `tan`, `asin`, `acos`, `atan`, `atan2`), the hyperbolic family (`sinh`, `cosh`, `sinh_cosh`, - `tanh`, `asinh`, `acosh`, `atanh`), and `hypot` are guaranteed-correctly rounded via a Ziv retry - loop (`Context::ziv`/`ziv_pair`, driven by the `ErrorBounds` preimage). Series transcendentals - (trig, atan) use near-correct `_compute` cores the wrapper certifies; composition transcendentals - treat the Ziv-correct primitives as black boxes. Remaining: `powf` (its `exp(y·ln x)` amplification - needs a dedicated radius treatment), and `dashu-cmplx`'s complex transcendental *wrappers* (which - route through these now-correct real primitives but aren't themselves Ziv-wrapped). + `tanh`, `asinh`, `acosh`, `atanh`), `hypot`, and `powf` (non-integer exponent) are + guaranteed-correctly rounded via a Ziv retry loop (`Context::ziv`/`ziv_pair`, driven by the + `ErrorBounds` preimage). Series transcendentals (trig, atan) use near-correct `_compute` cores the + wrapper certifies; composition transcendentals treat the Ziv-correct primitives as black boxes. + `powf`'s `exp(y·ln x)` amplification is handled by a work-precision radius + (`result.ulp()·(|y·ln x|+1)·(B+8)`) that shrinks as `B^{-guard}`, so the containment test + converges; an integer-valued `powf` exponent delegates to `powi` (≤ 1 ulp). Remaining: making + `powi` itself Ziv-correctly-rounded (currently near-correct, which the `powf` integer-exponent + path inherits), and `dashu-cmplx`'s complex transcendental *wrappers* (which route through these + now-correct real primitives but aren't themselves Ziv-wrapped). - **Signed-zero preservation in `CBig` zero short-circuits.** `sin_cos` and `sqr` take a fast path on exactly-zero input that returns `+0` components, so several Annex-G / IEEE signed-zero cases are not preserved (all numerically equal to `+0`, hence deferred): diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index 32eb3240..bd4d8c56 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -6,6 +6,7 @@ - `Repr::new_const`: a `const`-evaluable, normalized `Repr` constructor from a `DoubleWord` significand (the `const` counterpart of `Repr::new`). `FBig::from_parts_const` now delegates to it, and the complex literal macro uses it. +- `Repr::is_int` is now `const` (it only inspects the exponent and the infinity sentinel). - **Exact `Add`/`Sub`/`Mul` operators for `Repr`** (in a new `repr_ops` module). A `Repr` carries no precision limit, so add/sub/mul on it are lossless — these are the shared primitives the crate uses for exact intermediates (the Ziv containment test, the correctly-rounded `Sum`, and the `FBig` @@ -24,14 +25,20 @@ - **Guaranteed-correct rounding for most remaining transcendentals** via the Ziv loop: the trigonometric family (`sin`, `cos`, `sin_cos`, `tan`, `asin`, `acos`, `atan`, `atan2`), the hyperbolic family (`sinh`, `cosh`, `sinh_cosh`, `tanh`, `asinh`, `acosh`, `atanh`), and `hypot`. - (`powf` remains near-correct: its `exp(y·ln x)` composition amplifies the rounding by the result - magnitude, and the resulting data-dependent radius makes the Ziv containment test converge poorly - for large results — a dedicated radius treatment is deferred.) The trig series (`sin`/`cos`/`atan`) + The trig series (`sin`/`cos`/`atan`) are factored into near-correct `_compute` cores like `exp_compute`; the composition-based functions treat the now-Ziv-correct `exp`/`ln`/`atan` as black boxes and count only their arithmetic. The trig argument reduction folds a `|k|·ulp(π/2)` reduction-error term into the radius so the containment test stays sound for huge `|x|`. `ziv_pair` certifies both halves of `sin_cos`/ `sinh_cosh`. +- **Guaranteed-correct rounding for `powf` (non-integer exponent)** via the Ziv loop. `x^y = + exp(y·ln x)`, and `exp` amplifies the rounding of `y·ln x` by the result magnitude — so the + radius is `result.ulp()·(|y·ln x|+1)·(B+8)` taken at the *working* precision: it shrinks as + `B^{-guard}`, so the containment test converges (a radius computed at unlimited precision would + be constant across retries and never settle for a value near a rounding boundary). An + integer-valued exponent now delegates to `powi` (binary exponentiation), which also admits a + negative base — its sign fixed by the exponent's parity — so `powf(-x, n)` is in domain for + integer `n`. That integer-exponent path stays within 1 ulp (near-correct), matching `powi`. ### Change - **(breaking, bound)** `Context::exp`/`exp_m1`/`ln`/`ln_1p` (and `powf`, the hyperbolic family, diff --git a/float/src/exp.rs b/float/src/exp.rs index 09f5378d..a5b4cb45 100644 --- a/float/src/exp.rs +++ b/float/src/exp.rs @@ -8,7 +8,7 @@ use crate::{ round::{ErrorBounds, Round}, utils::ceil_usize, }; -use dashu_base::{AbsOrd, Approximation::*, BitTest, DivRemEuclid, EstimatedLog2, Sign}; +use dashu_base::{Abs, AbsOrd, Approximation::*, BitTest, DivRemEuclid, EstimatedLog2, Sign}; use dashu_int::IBig; impl FBig { @@ -298,12 +298,16 @@ pub(crate) fn exp_overflows( >::try_from(s_probe).is_err() } -// `powf`/`exp`/`exp_m1` are correctly rounded (via the Ziv loop for exp/exp_m1, and via the -// Ziv-backed ln/exp primitives for powf), so they require `R: ErrorBounds`. +// `powf` (non-integer exponent), `exp`, and `exp_m1` are correctly rounded via the Ziv loop, so +// they require `R: ErrorBounds`. `powf` with an integer-valued exponent delegates to `powi` +// (`R: Round`, near-correct within 1 ulp). impl Context { /// Raise the floating point number to an floating point power under this context. /// - /// Note that this method will not rely on [FBig::powi] even if the `exp` is actually an integer. + /// A non-integer exponent is correctly rounded via a Ziv loop. An integer-valued exponent + /// delegates to [`powi`](Context::powi) (binary exponentiation), which also accepts a negative + /// base — its sign is fixed by the exponent's parity — so `pow(-x, n)` is in domain here for + /// integer `n`. The integer-exponent path is within 1 ulp (near-correct), matching `powi`. /// /// # Examples /// @@ -333,7 +337,7 @@ impl Context { if base.is_infinite() || exp.is_infinite() { return Err(FpError::InfiniteInput); } - assert_limited_precision(self.precision); // TODO: we can allow it if exp is integer + assert_limited_precision(self.precision); // shortcuts if exp.is_pos_zero() || exp.is_neg_zero() { @@ -361,23 +365,61 @@ impl Context { // pow(1, y) = 1 for any finite y (exp is finite here — infinities were rejected above). return Ok(Exact(FBig::ONE)); } + + // Integer-valued exponent: delegate to the integer-power kernel (binary exponentiation). + // This sidesteps the `exp(y·ln x)` amplification entirely, and lets a negative base through + // — `powi` fixes the sign from the exponent's parity. `powi` is near-correct (≤ 1 ulp). + // Gated on `is_int` (a cheap exponent check) so the non-integer common case skips `to_int`. + if exp.is_int() { + return self.powi(base, exp.to_int().value()); + } + if base.sign() == Sign::Negative { - // TODO: we should allow negative base when exp is an integer + // A non-integer exponent on a negative base has no real value. return Err(FpError::OutOfDomain); } - // x^y = exp(y·ln x). Near-correctly rounded (guard-digit recipe): `ln` and `exp` are - // themselves Ziv-correct at the working precision, so this is within 1 ulp. A Ziv wrapper - // for `powf` is deferred — the data-dependent `exp` amplification (`result · ulp(y·ln x)`) - // makes the containment test converge poorly for large-magnitude results, so it needs a - // dedicated radius treatment before it's worth the cost. - let guard_digits = 10 + ceil_usize(self.precision.log2_est()); - let work_context = Context::::new(self.precision + guard_digits); - let ln_val = work_context.unwrap_fp(work_context.ln(base, reborrow_cache(&mut cache))); - let mul_val = work_context.unwrap_fp(work_context.mul(ln_val.repr(), exp)); - let exp_val = - work_context.unwrap_fp(work_context.exp(mul_val.repr(), reborrow_cache(&mut cache))); - Ok(exp_val.with_precision(self.precision)) + // x^y = exp(y·ln x), correctly rounded via the Ziv loop. `ln` and `exp` are themselves + // Ziv-correct at the working precision, so the radius comes only from the rounding of the + // `ln`/`mul`/`exp` chain — but `exp` AMPLIFIES the absolute error of its argument `y·ln x` + // by the result magnitude, i.e. by a relative factor of `|y·ln x|`. The radius is + // `result.ulp() · (|y·ln x| + 1) · (B + 8)` where `result.ulp()` is taken at the *working* + // precision, so it shrinks as `B^{-guard}` and the containment test converges. (A radius + // computed at unlimited precision would be constant across retries and never converge for + // a value near a rounding boundary.) The `B + 8` scale covers the `ulp`-vs-`value·B^{1-P}` + // gap plus a safety margin for the chained roundings. + // + // The overflow case is hoisted out of the Ziv closure (which can't return `Err`): if + // `exp(y·ln x)` falls outside the finite exponent range, short-circuit before the loop. + let probe = Context::::new(self.precision + 32); + let ln_x_probe = probe.ln(base, reborrow_cache(&mut cache))?.value(); + let arg_probe = probe.mul(ln_x_probe.repr(), exp)?.value(); + if exp_overflows::(&probe, arg_probe.repr(), &mut cache) { + return Err(if arg_probe.sign() == Sign::Positive { + FpError::Overflow(Sign::Positive) + } else { + FpError::Underflow(Sign::Positive) + }); + } + + let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + Ok(self.ziv(initial_guard, |guard| { + let work = Context::::new(self.precision + guard); + let ln_x = work.ln(base, reborrow_cache(&mut cache)).unwrap().value(); + let arg = work.mul(ln_x.repr(), exp).unwrap().value(); + let result = work + .exp(arg.repr(), reborrow_cache(&mut cache)) + .unwrap() + .value(); + + // Radius at unlimited precision (exact arithmetic), but built from the *work-precision* + // `result.ulp()` so it carries the `B^{-(p+guard)}` scale and shrinks across retries. + let ulp_w = result.ulp().with_precision(0).value(); + let arg_abs = arg.abs().with_precision(0).value(); + let scale = (B as i32) + 8; + let radius = (ulp_w * (arg_abs + FBig::::ONE)) * scale; + (result, radius) + })) } /// Calculate the exponential function (`eˣ`) on the floating point number under this context. @@ -561,4 +603,29 @@ mod tests { assert!(r.repr().is_neg_zero()); let _ = DBig::ZERO; } + + #[test] + fn test_powf_integer_exponent() { + use crate::DBig; + let ctx = Context::::new(53); + // integer-valued float exponent delegates to powi and supports a negative base (its sign + // is fixed by the exponent's parity): (-5)^3 = -125. + let neg_base = &Repr::<2>::new((-5).into(), 0); + let exp3 = &Repr::<2>::new(3.into(), 0); + let via_powf = ctx.powf::<2>(neg_base, exp3, None).unwrap().value(); + let via_powi = ctx.powi::<2>(neg_base, 3.into()).unwrap().value(); + assert_eq!(via_powf.repr(), via_powi.repr()); + assert_eq!(via_powf.repr().sign(), Sign::Negative); + + // a non-integer exponent on a negative base is out of domain (no real value) + let exp_half = &Repr::<2>::new(5.into(), -1); // 2.5 + assert_eq!(ctx.powf::<2>(neg_base, exp_half, None), Err(FpError::OutOfDomain)); + + // positive base, integer exponent: also routes through powi + let pos_base = &Repr::<2>::new(3.into(), 0); + let exp4 = &Repr::<2>::new(4.into(), 0); + let r = ctx.powf::<2>(pos_base, exp4, None).unwrap().value(); + assert_eq!(r.repr(), ctx.powi::<2>(pos_base, 4.into()).unwrap().value().repr()); + let _ = DBig::ZERO; + } } diff --git a/float/src/repr.rs b/float/src/repr.rs index 20913bac..d3e6d93f 100644 --- a/float/src/repr.rs +++ b/float/src/repr.rs @@ -296,7 +296,7 @@ impl Repr { /// assert!(Repr::<10>::one().is_int()); /// assert!(!Repr::<16>::new(123.into(), -1).is_int()); /// ``` - pub fn is_int(&self) -> bool { + pub const fn is_int(&self) -> bool { if self.is_infinite() { false } else { diff --git a/float/tests/exp_log_root_prop.rs b/float/tests/exp_log_root_prop.rs index b4f52ade..fde193d9 100644 --- a/float/tests/exp_log_root_prop.rs +++ b/float/tests/exp_log_root_prop.rs @@ -276,4 +276,20 @@ proptest! { let r_2p = y_2p.atan2(&x_2p).with_precision(P).value(); prop_assert_eq!(r_p, r_2p); } + + /// Correct-rounding self-oracle for powf. `base > 0` keeps it in domain; the exponent is kept + /// non-integer (m·10⁻⁴ with m % 10000 ≠ 0) so the Ziv path is exercised — integer-valued + /// exponents delegate to `powi` and are covered by `test_powf_integer_exponent`. The wide + /// exponent range (up to ±200) stresses the `exp(y·ln x)` amplification, which is exactly the + /// case the work-precision radius (vs the old unlimited-precision one) must converge on. + #[test] + fn powf_rounding_exact_oracle(b in pos_x(100_000), m in -2_000_000i64..=2_000_000i64) { + prop_assume!(m != 0 && m % 10000 != 0); + let e = x_from(m); + let r_p = b.clone().powf(&e); + let b_2p = b.clone().with_precision(2 * P).value(); + let e_2p = e.clone().with_precision(2 * P).value(); + let r_2p = b_2p.powf(&e_2p).with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } } diff --git a/guide-zh/src/compliance.md b/guide-zh/src/compliance.md index 91b4734a..eb9c2ddf 100644 --- a/guide-zh/src/compliance.md +++ b/guide-zh/src/compliance.md @@ -51,7 +51,7 @@ | IEEE 754 要求 | 合规性 | 说明 | |---------------------|-----------|-------| | 舍入模式:roundTiesToEven、roundTiesToAway、roundTowardPositive、roundTowardNegative、roundTowardZero | ✅ | 全部五种模式实现为 `HalfEven`、`HalfAway`、`Up`、`Down`、`Zero`。 | -| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及实超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`)通过 Ziv 循环保证正确舍入;`powf` 与 `dashu-cmplx` 的复数超越函数*包装层*仍在 1 ulp 以内(经如今正确舍入的实原语的保护位组合计算)。`Rounded` 类型区分精确与非精确结果。 | +| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及实超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`)通过 Ziv 循环保证正确舍入;整数指数的 `powf` 委托给 `powi`(在 1 ulp 以内),`dashu-cmplx` 的复数超越函数*包装层*经由如今正确舍入的实原语计算(在 1 ulp 以内)。`Rounded` 类型区分精确与非精确结果。 | | 就近舍入保持零的符号 | ✅ | `rounded_to_repr` 在舍入将非零值折叠为零时保持输入符号。 | #### 第 5.6 节——符号位运算 diff --git a/guide-zh/src/faq.md b/guide-zh/src/faq.md index 26936618..0b80993b 100644 --- a/guide-zh/src/faq.md +++ b/guide-zh/src/faq.md @@ -26,7 +26,7 @@ ## 已知限制 - **没有 NaN。** 无效运算在便捷层会 panic,在上下文层返回 `Err(FpError)`。无穷是终态值,不是操作数——参见[标准合规性](./compliance.md)。 -- **正确舍入的超越函数。** 实超越函数——`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`——均通过 Ziv 重试循环保证正确舍入。`powf` 与 `dashu-cmplx` 的复数超越函数*包装层*仍为近似正确(1 ulp 以内),但其内部已调用如今正确舍入的实原语。 +- **正确舍入的超越函数。** 实超越函数——`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`——均通过 Ziv 重试循环保证正确舍入。整数指数的 `powf` 委托给 `powi`(二进制幂运算,1 ulp 以内),`dashu-cmplx` 的复数超越函数*包装层*仍为近似正确(1 ulp 以内),但其内部已调用如今正确舍入的实原语。 - **复数功能覆盖。** `CBig` 提供域运算和基本超越函数;复数双曲函数、`fma` 及其他若干功能推迟到 0.5.x(参见 v0.5 版本说明)。 - **没有球体/区间运算。** 与 MPC 的实验性 `mpcb_t`(复数球体)不同,`dashu` 不提供区间或球体类型。这是一个刻意的范围选择:球体运算在上游仍是实验性的,如果/当它稳定后,最好由一个**独立的 crate** 在 `dashu-float`/`dashu-cmplx` 之上提供,而非耦合到核心类型。 - **尚无 SIMD-FFT 乘法**(计划在 v1.0 中实现)。 diff --git a/guide/src/compliance.md b/guide/src/compliance.md index 759ae57a..d3670698 100644 --- a/guide/src/compliance.md +++ b/guide/src/compliance.md @@ -56,7 +56,7 @@ The reference here is IEEE Std 754™-2008 (ISO/IEC/IEEE 60559:2011). | IEEE 754 requirement | Compliance | Notes | |---------------------|-----------|-------| | Rounding modes: roundTiesToEven, roundTiesToAway, roundTowardPositive, roundTowardNegative, roundTowardZero | ✅ | All five modes implemented as `HalfEven`, `HalfAway`, `Up`, `Down`, `Zero`. | -| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the real transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`) are guaranteed-correctly rounded via a Ziv loop; `powf` and `dashu-cmplx`'s complex transcendental *wrappers* are within 1 ulp (guard-digit composition routing through the now-correct real primitives). The `Rounded` type distinguishes exact from inexact results. | +| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the real transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` for a non-integer exponent) are guaranteed-correctly rounded via a Ziv loop; an integer-valued `powf` exponent delegates to `powi` (within 1 ulp), and `dashu-cmplx`'s complex transcendental *wrappers* route through the now-correct real primitives (within 1 ulp). The `Rounded` type distinguishes exact from inexact results. | | Round-to-nearest preserves sign of zero | ✅ | `rounded_to_repr` preserves input sign when rounding collapses a non-zero to zero. | #### Section 5.6 — Sign bit operations diff --git a/guide/src/faq.md b/guide/src/faq.md index ccb0fa32..826ca297 100644 --- a/guide/src/faq.md +++ b/guide/src/faq.md @@ -29,7 +29,7 @@ Compared with other Rust crates: ## Known limitations - **No NaN.** Invalid operations panic at the convenience layer and return `Err(FpError)` at the context layer. Infinities are terminal values, not operands — see [Standards Compliance](./compliance.md). -- **Correctly-rounded transcendentals.** The real transcendentals — `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot` — are guaranteed-correctly rounded via a Ziv retry loop. `powf` and `dashu-cmplx`'s complex transcendental *wrappers* are still near-correct (within 1 ulp), routing through the now-correct real primitives. +- **Correctly-rounded transcendentals.** The real transcendentals — `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` (non-integer exponent) — are guaranteed-correctly rounded via a Ziv retry loop. An integer-valued `powf` exponent delegates to `powi` (binary exponentiation, within 1 ulp), and `dashu-cmplx`'s complex transcendental *wrappers* are still near-correct (within 1 ulp), routing through the now-correct real primitives. - **Complex surface.** `CBig` ships field arithmetic and the elementary transcendentals; complex hyperbolics, `fma`, and several others are deferred to 0.5.x (see the v0.5 release notes). - **No ball/interval arithmetic.** Unlike MPC's experimental `mpcb_t` (complex balls), `dashu` does not provide interval or ball types. This is a deliberate scope choice: ball arithmetic is From d9f1f18f30160ba18511701d1c272558433fa9de Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Mon, 20 Jul 2026 14:56:40 +0800 Subject: [PATCH 06/21] Fix acos(1) hang under directed rounding acos(1) = pi/2 - asin(1) cancels to exactly 0, but the composition carries a positive radius. Under a directed rounding mode (Down/Zero/Up) the preimage of 0 is one-sided ([0, ulp)), so the Ziv containment test can never certify it -- any positive radius dips the interval below 0 -- and it infinite-retried. This was the signed_zero.rs full-file hang: test_asin_acos_unit_under_down hung on acos(1) under Down. Short-circuit x = 1 to the exact 0 before the Ziv loop, matching asin's existing |x| = 1 special case. (asin(+-1) = +-pi/2 and acos(-1) = pi are nonzero, so they were already fine.) Co-Authored-By: Claude --- float/CHANGELOG.md | 7 +++++++ float/src/math/trig.rs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index bd4d8c56..e3ffef50 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -48,6 +48,13 @@ affected (custom modes are already discouraged). `CachedFBig`/`CachedCBig` forwarders for these methods carry the same bound. Arithmetic, roots, trigonometric, and `powi` remain `R: Round`. +### Fix +- **`acos(1)` under directed rounding** (Down/Zero/Up) no longer hangs. `acos(1) = π/2 − + asin(1)` cancels to exactly `0`, but the composition carries a positive radius; under a directed + mode `0`'s rounding preimage is one-sided (`[0, ulp)`), so the Ziv containment test could never + certify it (any positive radius dips the interval below `0`) and infinite-retried. `acos` now + short-circuits `x = 1` to the exact `0`, matching `asin`'s `|x| = 1` special case. + ## 0.5.0 ### Add diff --git a/float/src/math/trig.rs b/float/src/math/trig.rs index 87410925..6ad62300 100644 --- a/float/src/math/trig.rs +++ b/float/src/math/trig.rs @@ -450,6 +450,13 @@ impl Context { if x_orig.abs_cmp(&FBig::ONE).is_gt() { return Err(FpError::OutOfDomain); } + // acos(1) = 0 exactly. The composition π/2 − asin(1) cancels to exactly 0 but carries a + // positive radius, and under directed rounding 0's preimage is one-sided ([0, ulp)), so + // the Ziv containment test can never certify it (any positive radius dips the interval + // below 0) and would infinite-retry. Short-circuit to the exact value. + if x.is_one() { + return Ok(Exact(FBig::::new(Repr::zero(), *self))); + } Ok(self.ziv(50, |guard| { let work = Context::::new(self.precision + guard); From 2ca4c67310c26679f8713b11d2f6d08344ab6deb Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Mon, 20 Jul 2026 15:16:44 +0800 Subject: [PATCH 07/21] Special-case asin(0) and acos(-1) under directed rounding A scan of every transcendental at its exact-representable special inputs under Down/Up/Zero turned up two more instances of the acos(1) hang (a transcendental whose true value is exactly representable carries a positive radius that can't be certified against the value's one-sided preimage, so Ziv infinite-retries): - asin(0) = 0: asin lacked the x=0 short-circuit that asinh/atanh/sin/cos/... all have, so it computed atan(0)=0 with a positive radius and infinite-retried. Now short-circuits to +-0 (asin is odd), matching the rest of the family. - acos(-1) = pi: short-circuited for symmetry with acos(1). The scan confirmed no other transcendentals hang (sqrt of perfect squares, exp(0), cos(0), cosh(0), powf(x,0), nth_root of exact roots, etc. are all already special-cased or detect exact results). Remaining: hypot of an exact Pythagorean triple (e.g. hypot(3,4)=5) -- same containment class but no clean single special case, plus a dashu-int NTT crash hit during the retry; tracked separately. Co-Authored-By: Claude --- float/CHANGELOG.md | 14 +++++++++----- float/src/math/trig.rs | 26 ++++++++++++++++++-------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index e3ffef50..58c91580 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -49,11 +49,15 @@ methods carry the same bound. Arithmetic, roots, trigonometric, and `powi` remain `R: Round`. ### Fix -- **`acos(1)` under directed rounding** (Down/Zero/Up) no longer hangs. `acos(1) = π/2 − - asin(1)` cancels to exactly `0`, but the composition carries a positive radius; under a directed - mode `0`'s rounding preimage is one-sided (`[0, ulp)`), so the Ziv containment test could never - certify it (any positive radius dips the interval below `0`) and infinite-retried. `acos` now - short-circuits `x = 1` to the exact `0`, matching `asin`'s `|x| = 1` special case. +- **Directed-rounding hang on exact results** (`acos(1)`, `acos(-1)`, `asin(0)`): under a directed + mode (Down/Up/Zero) a transcendental whose true value is exactly representable carries a positive + radius that can't be certified against the value's one-sided rounding preimage, so the Ziv + containment test infinite-retried (and the retry eventually tripped a `dashu-int` NTT assertion). + `acos` now short-circuits `|x| = 1` to the exact `0` / `π`, and `asin` short-circuits `x = 0` to + `±0` — matching the existing zero/`|x|=1` special cases in the rest of the inverse trig/hyperbolic + family. (`hypot` of an exact Pythagorean triple, e.g. `hypot(3,4) = 5`, still hits this under + directed rounding — it has no clean single special case; the underlying `dashu-int` NTT crash is + tracked separately.) ## 0.5.0 diff --git a/float/src/math/trig.rs b/float/src/math/trig.rs index 6ad62300..2698a6a3 100644 --- a/float/src/math/trig.rs +++ b/float/src/math/trig.rs @@ -390,6 +390,12 @@ impl Context { return Err(FpError::InfiniteInput); } assert_limited_precision(self.precision); + if x.significand.is_zero() { + // asin(±0) = ±0 (asin is odd), exact. Like the other inverse trig/hyperbolic functions, + // short-circuit before the Ziv loop: a zero result carries a positive radius that can't + // be certified against 0's one-sided preimage under directed rounding. + return signed_zero_normal(self, x); + } let x_orig = FBig::::new(x.clone(), *self); // Domain check: |x| must be <= 1 @@ -446,16 +452,20 @@ impl Context { assert_limited_precision(self.precision); let x_orig = FBig::::new(x.clone(), *self); - // Domain check: |x| must be <= 1 - if x_orig.abs_cmp(&FBig::ONE).is_gt() { + let cmp_one = x_orig.abs_cmp(&FBig::ONE); + if cmp_one.is_gt() { return Err(FpError::OutOfDomain); } - // acos(1) = 0 exactly. The composition π/2 − asin(1) cancels to exactly 0 but carries a - // positive radius, and under directed rounding 0's preimage is one-sided ([0, ulp)), so - // the Ziv containment test can never certify it (any positive radius dips the interval - // below 0) and would infinite-retry. Short-circuit to the exact value. - if x.is_one() { - return Ok(Exact(FBig::::new(Repr::zero(), *self))); + if cmp_one.is_eq() { + // |x| = 1: the composition π/2 − asin(±1) cancels onto an exact value. acos(1) = 0 is + // the acute case — under directed rounding 0's preimage is one-sided ([0, ulp)), so the + // Ziv containment test can never certify it (any positive radius dips the interval below + // 0) and would infinite-retry. acos(-1) = π is handled here too, for symmetry. + return Ok(if x.sign() == Sign::Positive { + Exact(FBig::::new(Repr::zero(), *self)) + } else { + self.pi::(reborrow_cache(&mut cache)) + }); } Ok(self.ziv(50, |guard| { From a482d7ec373c2f5636bc8cc23e4f2dcc3ea076a3 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Mon, 20 Jul 2026 15:32:47 +0800 Subject: [PATCH 08/21] Fix NTT sqr/mul panic on all-zero operand add_signed_sqr_conv and add_signed_mul_conv assumed a non-zero input (debug_assert!(la_bits > 0)); an all-zero slice panicked in debug and indexed out of bounds in release. sqrt_rem of a perfect square with many trailing zero words squares the estimate's all-zero low half to verify the remainder, so this was reachable -- and was the crash behind dashu-float's hypot(3,4) under directed rounding (the Ziv retry drove sqrt to a precision where the scaled significand hit the NTT path). An all-zero operand now returns early: the product is zero, so c += sign*a*b (or a^2) is a no-op. This matches the existing zero-guard in the chunked-multiply closure. Regression test: sqrt_rem of (1<<560000)^2. Co-Authored-By: Claude --- integer/CHANGELOG.md | 11 +++++++++++ integer/src/mul/ntt/mod.rs | 8 ++++++-- integer/src/sqr/ntt.rs | 9 +++++++-- integer/tests/root.rs | 13 +++++++++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/integer/CHANGELOG.md b/integer/CHANGELOG.md index a478bd03..285784a8 100644 --- a/integer/CHANGELOG.md +++ b/integer/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Fix +- NTT squaring/multiplication of an all-zero operand no longer panics. `add_signed_sqr_conv` and + `add_signed_mul_conv` assumed a non-zero input (`debug_assert!(la_bits > 0)`), so an all-zero slice + panicked in debug and indexed out of bounds in release. Reachable from `sqrt_rem` of a perfect + square with many trailing zero words — it squares the all-zero low half of the estimate to verify + the remainder — which was the crash behind `dashu-float`'s `hypot(3,4)` under directed rounding. + An all-zero operand now returns early (the product is zero, so the signed accumulate is a no-op), + matching the existing zero-guard in the chunked-multiply closure. + ## 0.5.0 ### Add diff --git a/integer/src/mul/ntt/mod.rs b/integer/src/mul/ntt/mod.rs index fa51f428..2e5e569f 100644 --- a/integer/src/mul/ntt/mod.rs +++ b/integer/src/mul/ntt/mod.rs @@ -319,10 +319,14 @@ fn add_signed_mul_conv( let lb = b.len(); debug_assert!(la > 0 && lb > 0); - let (b_pack, nn, k_eff) = select_params(la, lb); let la_bits = bit_len(a); let lb_bits = bit_len(b); - debug_assert!(la_bits > 0 && lb_bits > 0); + if la_bits == 0 || lb_bits == 0 { + // Either operand is all-zero, so `a·b = 0` and `c += sign·a·b` is a no-op (carry 0, `c` + // unchanged). Reachable from `div_rem` multiplying a zero partial quotient/operand. + return 0; + } + let (b_pack, nn, k_eff) = select_params(la, lb); let coeffs_a = coeff_count(la_bits, b_pack); let coeffs_b = coeff_count(lb_bits, b_pack); diff --git a/integer/src/sqr/ntt.rs b/integer/src/sqr/ntt.rs index c612e7da..2bd983b7 100644 --- a/integer/src/sqr/ntt.rs +++ b/integer/src/sqr/ntt.rs @@ -33,9 +33,14 @@ pub(crate) fn add_signed_sqr_same_len( fn add_signed_sqr_conv(c: &mut [Word], sign: Sign, a: &[Word], memory: &mut Memory) -> SignedWord { let la = a.len(); debug_assert!(la > 0); - let (b_pack, nn, k_eff) = ntt::select_params(la, la); let la_bits = bit_len(a); - debug_assert!(la_bits > 0); + if la_bits == 0 { + // `a` is all-zero, so `a² = 0` and `c += sign·a²` is a no-op (carry 0, `c` unchanged). + // Reachable from `sqrt_rem_large` squaring the all-zero low half of a sqrt estimate for + // a perfect square with trailing zero words (e.g. `sqrt(25·2^k)`). + return 0; + } + let (b_pack, nn, k_eff) = ntt::select_params(la, la); let coeffs_a = coeff_count(la_bits, b_pack); let output_coeffs = 2 * coeffs_a - 1; diff --git a/integer/tests/root.rs b/integer/tests/root.rs index 3b165bc0..fb5fea0a 100644 --- a/integer/tests/root.rs +++ b/integer/tests/root.rs @@ -90,6 +90,19 @@ fn test_sqrt_negative_panic() { let _ = ibig!(-1).sqrt(); } +#[test] +fn test_sqrt_rem_perfect_square_trailing_zeros() { + // Regression: a perfect square whose estimate has many trailing zero bits. `sqrt_rem` squares + // the estimate's all-zero low half to verify the remainder, routing through the NTT sqr path + // which previously panicked on the zero input (`la_bits == 0`). The shift is large enough that + // the low half exceeds THRESHOLD_NTT (4000 words), forcing the NTT path. This is the + // `dashu-float` `hypot(3,4)` crash, reached directly through the integer sqrt. + let shift = 560_000usize; + let s = ubig!(1) << shift; + let n = &s * &s; + assert_eq!(n.sqrt_rem(), (s, ubig!(0))); +} + #[test] fn test_nth_root() { // the nth root of 0 is 0 (regression: it used to return 1 via the `bits <= n` shortcut) From d06a91eda5ea536932feafa407d5983a9cbbbfd1 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Mon, 20 Jul 2026 15:34:34 +0800 Subject: [PATCH 09/21] docs: update hypot note after the dashu-int NTT fix The float changelog's hypot note said the underlying dashu-int NTT crash was "tracked separately"; that crash is now fixed (a482d7e), so update the note to reflect that hypot(exact) still infinite-retries on the containment issue, but no longer crashes. Co-Authored-By: Claude --- float/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index 58c91580..69fe0cd0 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -55,9 +55,9 @@ containment test infinite-retried (and the retry eventually tripped a `dashu-int` NTT assertion). `acos` now short-circuits `|x| = 1` to the exact `0` / `π`, and `asin` short-circuits `x = 0` to `±0` — matching the existing zero/`|x|=1` special cases in the rest of the inverse trig/hyperbolic - family. (`hypot` of an exact Pythagorean triple, e.g. `hypot(3,4) = 5`, still hits this under - directed rounding — it has no clean single special case; the underlying `dashu-int` NTT crash is - tracked separately.) + family. (`hypot` of an exact Pythagorean triple, e.g. `hypot(3,4) = 5`, still infinite-retries + under directed rounding — it has no clean single special case. The `dashu-int` NTT crash that the + retry used to trip is now fixed, but the containment issue itself remains.) ## 0.5.0 From dab5cc18ebd9fb80f77f092d13f256771011c8ca Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Mon, 20 Jul 2026 16:23:25 +0800 Subject: [PATCH 10/21] Restructure hypot to sqrt(large^2 + small^2) with exactness tracking Replace the ratio form (large * sqrt(1 + (small/large)^2)) with the scaled direct form sqrt(large^2 + small^2), tracking each step's Exact/Inexact flag (MPFR's `exact` flag in hypot.c). When the whole chain is exact -- which holds for every Pythagorean-triple input -- the closure reports radius 0, which ziv accepts without the containment test. This is the only way to terminate an exactly-representable result under directed rounding, where the value sits on a one-sided preimage boundary. The ratio form broke this for triples like hypot(5,12)=13: 5/12 is a non-terminating binary fraction, so div returned Inexact even though the final result 13 is exact. The direct form has no division, so sqr/add/sqrt are all exact for integer inputs -- mirroring how MPFR avoids a division precisely so its exact flag works for every triple. Both operands are scaled down by k base-B digits before squaring (k chosen so large^2 can't overflow the exponent) and the root scaled back, exactly MPFR's sh. Regression tests: hypot of (3,4)/(5,12)/(8,15) under Down/Up/Zero all return the exact integer. Co-Authored-By: Claude --- float/CHANGELOG.md | 26 ++++++++++------ float/src/root.rs | 74 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 77 insertions(+), 23 deletions(-) diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index 69fe0cd0..52adcec7 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -48,16 +48,24 @@ affected (custom modes are already discouraged). `CachedFBig`/`CachedCBig` forwarders for these methods carry the same bound. Arithmetic, roots, trigonometric, and `powi` remain `R: Round`. +### Add +- **`hypot` is now correctly rounded via the Ziv loop** with MPFR-style exactness tracking. The + closure computes `sqrt(large² + small²)` (operands scaled to avoid `large²` overflowing the + exponent — no division) and OR's each step's `Exact`/`Inexact` flag, mirroring MPFR's `exact` + flag (`hypot.c`): an all-exact chain yields the exact true value, reported to `ziv` with radius 0, + which it accepts without the containment test. This is what lets an exact Pythagorean-triple + result (e.g. `hypot(3,4)=5`, `hypot(5,12)=13`) terminate under a directed rounding mode, where + the one-sided preimage would otherwise make the containment test infinite-retry. + ### Fix -- **Directed-rounding hang on exact results** (`acos(1)`, `acos(-1)`, `asin(0)`): under a directed - mode (Down/Up/Zero) a transcendental whose true value is exactly representable carries a positive - radius that can't be certified against the value's one-sided rounding preimage, so the Ziv - containment test infinite-retried (and the retry eventually tripped a `dashu-int` NTT assertion). - `acos` now short-circuits `|x| = 1` to the exact `0` / `π`, and `asin` short-circuits `x = 0` to - `±0` — matching the existing zero/`|x|=1` special cases in the rest of the inverse trig/hyperbolic - family. (`hypot` of an exact Pythagorean triple, e.g. `hypot(3,4) = 5`, still infinite-retries - under directed rounding — it has no clean single special case. The `dashu-int` NTT crash that the - retry used to trip is now fixed, but the containment issue itself remains.) +- **Directed-rounding hang on exact results** (`acos(1)`, `acos(-1)`, `asin(0)`, `hypot` of a + Pythagorean triple): under a directed mode (Down/Up/Zero) a function whose true value is exactly + representable carries a positive radius that can't be certified against the value's one-sided + rounding preimage, so the Ziv containment test infinite-retried (and the retry eventually tripped + a `dashu-int` NTT assertion, now fixed). `acos` short-circuits `|x| = 1` to the exact `0` / `π`, + `asin` short-circuits `x = 0` to `±0` (matching the existing special cases in the rest of the + inverse trig/hyperbolic family), and `hypot` reports radius 0 when its computation chain is exact + (see the new `hypot` entry above). ## 0.5.0 diff --git a/float/src/root.rs b/float/src/root.rs index 8279dcfe..3e3cef0d 100644 --- a/float/src/root.rs +++ b/float/src/root.rs @@ -7,10 +7,23 @@ use crate::{ error::{assert_limited_precision, panic_root_zeroth, FpError, FpResult}, fbig::FBig, repr::{Context, Repr, Word}, - round::{ErrorBounds, Round}, + round::{ErrorBounds, Round, Rounded}, utils::{shl_digits, split_digits_ref}, }; +/// Take the value of a [`Rounded`] result, recording in `exact` whether it was computed exactly. +/// +/// Mirrors MPFR's `exact` flag: an all-exact operation chain yields the exact true value, which a +/// Ziv closure can report with radius 0 — `ziv` then accepts it without the containment test, +/// which otherwise can't certify an exactly-representable result (it sits on a one-sided preimage +/// boundary under directed rounding). +fn value_tracking_exact(r: Rounded, exact: &mut bool) -> T { + if !matches!(r, Approximation::Exact(_)) { + *exact = false; + } + r.value() +} + impl SquareRoot for FBig { type Output = Self; #[inline] @@ -300,10 +313,10 @@ impl Context { return Ok(self.repr_round_ref(&large).map(|v| FBig::new(v, *self))); } - // The result is `large · sqrt(1 + (small/large)²)`, i.e. ∈ [large, large·√2]. It overflows - // only when `large` is so large that this product reaches the infinity sentinel exponent — - // unreachable for real inputs, but pre-checked here so the Ziv closure can use infallible - // `FBig` arithmetic. + // The result is `sqrt(large² + small²)`, i.e. ∈ [large, large·√2]. It overflows only when + // `large` is so large that the result reaches the infinity sentinel exponent — unreachable + // for real inputs, but pre-checked here so the Ziv closure can use infallible `FBig` + // arithmetic. if large.exponent >= isize::MAX - 1 { return Err(FpError::Overflow(Sign::Positive)); } @@ -311,15 +324,30 @@ impl Context { let initial_guard = crate::utils::ceil_usize(self.precision.log2_est()) + 10; Ok(self.ziv(initial_guard, |guard| { let gctx = Context::::new(self.precision + guard); - let large_f = FBig::new(gctx.repr_round_ref(&large).value(), gctx); - let small_f = FBig::new(gctx.repr_round_ref(&small).value(), gctx); - // r = small/large ∈ [0,1]; 1 + r² ∈ [1,2]; result = large · sqrt(1 + r²). Every op is - // correctly rounded at the working precision; the radius is a small multiple of the - // result's working ULP (the sqrt attenuates, no condition amplification). - let r = &small_f / &large_f; - let root = (FBig::::ONE + r.sqr()).sqrt(); - let result = &large_f * &root; - let radius = result.ulp() * 8; + // result = sqrt(large² + small²), with both operands scaled down by `k` base-B digits + // before squaring (so `large²` can't overflow the exponent) and the root scaled back: + // sqrt(L² + S²) · B^k = sqrt(large² + small²) for L = large·B⁻ᵏ, S = small·B⁻ᵏ. No + // division — so for integer inputs every step is exact (MPFR's `exact` flag), and an + // all-exact chain yields the exact true value. Report radius 0 then, which `ziv` + // accepts without the containment test (it can't certify an exactly-representable result + // under directed rounding — e.g. hypot(3,4)=5, hypot(5,12)=13 — which sits on a + // one-sided preimage boundary). + let k = (large.exponent as i128 - (isize::MAX as i128 - 2) / 2).max(0) as isize; + let mut exact = true; + let large_f = + FBig::new(value_tracking_exact(gctx.repr_round_ref(&large), &mut exact), gctx); + let small_f = + FBig::new(value_tracking_exact(gctx.repr_round_ref(&small), &mut exact), gctx); + let l_sq = value_tracking_exact(gctx.sqr((large_f >> k).repr()).unwrap(), &mut exact); + let s_sq = value_tracking_exact(gctx.sqr((small_f >> k).repr()).unwrap(), &mut exact); + let sum = value_tracking_exact(gctx.add(l_sq.repr(), s_sq.repr()).unwrap(), &mut exact); + let root = value_tracking_exact(gctx.sqrt(sum.repr()).unwrap(), &mut exact); + let result = root << k; // exact exponent shift — scales back, doesn't affect `exact` + let radius = if exact { + FBig::::ZERO + } else { + result.ulp() * 8 + }; (result, radius) })) } @@ -385,6 +413,24 @@ mod tests { assert_eq!(r.repr().sign(), Sign::Positive); } + fn check_hypot_exact_triples(ctx: Context) { + let mk = |v: i32| Repr::<2>::new(v.into(), 0); + // Pythagorean triples: the result is exactly representable, so under a directed mode it + // sits on a one-sided preimage boundary. The closure must terminate (radius 0 from the + // all-exact `sqrt(large²+small²)` chain) rather than infinite-retry. + for (a, b, h) in [(3, 4, 5), (5, 12, 13), (8, 15, 17)] { + let r = ctx.hypot(&mk(a), &mk(b)).unwrap().value(); + assert_eq!(r.repr().significand(), &h.into(), "hypot({a}, {b})"); + } + } + + #[test] + fn test_hypot_exact_under_directed_rounding() { + check_hypot_exact_triples(Context::::new(53)); + check_hypot_exact_triples(Context::::new(53)); + check_hypot_exact_triples(Context::::new(53)); + } + #[test] fn test_hypot_no_spurious_overflow() { // a value whose square would collide with the +inf sentinel exponent, but whose From fa6d0f081d65495c5287af873526799906afcdd8 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Tue, 21 Jul 2026 10:36:11 +0800 Subject: [PATCH 11/21] Address review: float cleanup + tan pole-check removal - tan: drop the hoisted pole check. It re-evaluated the sin/cos series a second time on top of the first Ziv attempt (~2x per call), and guarded a condition that can't occur: near a pole the value is large but finite, and dashu's wide exponent range holds it (sign carried by the arithmetic, s/-|c| is negative); the reduced argument cancels to exactly zero only when the input equals the work-precision pi/2, which the trig guard inflation rules out. The closure's significand-zero retry guard handles that unreachable case. Matches MPFR's structure (no pole special-case). - Dedup helpers: Context::base_guard_digits (ceil(log_B(precision))) across the 12 transcendental Ziv guard sites; series_radius (ulp*(4*terms+12)) across the sin/cos/sin_cos/atan/ln series cores; merge the two textually identical CachedFBig forwarding macros. Co-Authored-By: Claude --- float/CHANGELOG.md | 14 ++++++ float/src/exp.rs | 5 +- float/src/fbig_cached_ops.rs | 34 ++++---------- float/src/log.rs | 10 ++-- float/src/math/hyper.rs | 17 ++++--- float/src/math/trig.rs | 88 ++++++++++++++++++++++++------------ float/src/repr.rs | 12 ++++- 7 files changed, 109 insertions(+), 71 deletions(-) diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index 52adcec7..8e5951bc 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -47,6 +47,11 @@ All six built-in modes satisfy `ErrorBounds`; only custom non-`ErrorBounds` `Round` modes are affected (custom modes are already discouraged). `CachedFBig`/`CachedCBig` forwarders for these methods carry the same bound. Arithmetic, roots, trigonometric, and `powi` remain `R: Round`. +- **Internal dedup** (no behavior change): the `⌈log_B(precision)⌉` base-guard formula shared by every + transcendental Ziv loop is now `Context::base_guard_digits::()` (12 call sites in `hyper`/`exp`/ + `log`), and the `ulp·(4·terms + 12)` series-truncation radius is now `series_radius(value, terms)` + (shared by the `sin`/`cos`/`sin_cos`/`atan`/`ln` series cores). The two textually-identical + `CachedFBig` forwarding macros (`forward_to_context!` / `forward_to_context_unwrap!`) are merged. ### Add - **`hypot` is now correctly rounded via the Ziv loop** with MPFR-style exactness tracking. The @@ -66,6 +71,15 @@ `asin` short-circuits `x = 0` to `±0` (matching the existing special cases in the rest of the inverse trig/hyperbolic family), and `hypot` reports radius 0 when its computation chain is exact (see the new `hypot` entry above). +- **`tan` no longer has a hoisted pole check.** The check (which tested `cos` with `is_pos_zero`, + missing `-0`, and computed a `±∞` pole sign) re-evaluated the sin/cos series a second time on top of + the first Ziv attempt — a ~2× cost on every `tan` call. It's removed: near a pole (an odd multiple + of π/2) the value is large but finite, and dashu's wide exponent range holds it as a finite number + whose sign is carried by the arithmetic (`s/−|c|` is negative), so no `±∞` special-case is needed. + The unreachable exact-pole case (cos cancelling to a zero significand — impossible for finite- + precision input, since a `p`-digit rational can't sit closer than ~`B⁻ᵖ` to the irrational pole) is + handled by the Ziv-closure's `significand.is_zero()` retry guard, which forces a higher guard where + cos is representable. ## 0.5.0 diff --git a/float/src/exp.rs b/float/src/exp.rs index a5b4cb45..e5fd5f63 100644 --- a/float/src/exp.rs +++ b/float/src/exp.rs @@ -6,7 +6,6 @@ use crate::{ math::cache::{reborrow_cache, ConstCache}, repr::{Context, Repr, Word}, round::{ErrorBounds, Round}, - utils::ceil_usize, }; use dashu_base::{Abs, AbsOrd, Approximation::*, BitTest, DivRemEuclid, EstimatedLog2, Sign}; use dashu_int::IBig; @@ -402,7 +401,7 @@ impl Context { }); } - let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + let initial_guard = self.base_guard_digits::() + 10; Ok(self.ziv(initial_guard, |guard| { let work = Context::::new(self.precision + guard); let ln_x = work.ln(base, reborrow_cache(&mut cache)).unwrap().value(); @@ -539,7 +538,7 @@ impl Context { // rounding, plus `n` for the Bⁿ powering amplification — halved from the pre-Ziv `2n`, // since Ziv (not the guard count) now certifies correctness. `n ≈ √p` is derived from the // target precision and is constant across retries. - let series_guard = ceil_usize(self.precision.log2_est() / B.log2_est()); + let series_guard = self.base_guard_digits::(); let n = 1usize << (self.precision.bit_len() / 2); Ok(self.ziv(series_guard + n, |guard| { self.exp_compute::( diff --git a/float/src/fbig_cached_ops.rs b/float/src/fbig_cached_ops.rs index 713aa2ca..70c0e902 100644 --- a/float/src/fbig_cached_ops.rs +++ b/float/src/fbig_cached_ops.rs @@ -611,8 +611,9 @@ impl<'a, R: Round, const B: Word> Product<&'a CachedFBig> for CachedFBig`, panicking on -/// error and re-attaching the cache handle. Returns a bare `CachedFBig`. +/// Forward a unary transcendental to its [`Context`] method, panicking on error and re-attaching +/// the cache handle. The context method returns an [`FpResult`] whose rounding flag is dropped by +/// [`unwrap_fp`](crate::Context::unwrap_fp); the cached wrapper never surfaces it. macro_rules! forward_to_context { ($name:ident) => { #[doc = concat!("See [`FBig::", stringify!($name), "`].")] @@ -628,23 +629,6 @@ macro_rules! forward_to_context { }; } -/// Forward a unary function to a [`Context`] method returning `FpResult`, panicking on -/// error and discarding the rounding info. Returns a bare `CachedFBig`. -macro_rules! forward_to_context_unwrap { - ($name:ident) => { - #[doc = concat!("See [`FBig::", stringify!($name), "`].")] - #[inline] - pub fn $name(&self) -> CachedFBig { - let mut c = self.cache.borrow_mut(); - let fbig = self - .fbig - .context - .unwrap_fp(self.fbig.context.$name::(&self.fbig.repr, Some(&mut *c))); - CachedFBig::from_fbig(fbig, &self.cache) - } - }; -} - /// Forward a unary function that delegates to the inner [`FBig`] (no cache needed). macro_rules! forward_to_fbig { ($name:ident) => { @@ -686,12 +670,12 @@ impl CachedFBig { forward_to_context!(acosh); forward_to_context!(atanh); - forward_to_context_unwrap!(sin); - forward_to_context_unwrap!(cos); - forward_to_context_unwrap!(tan); - forward_to_context_unwrap!(asin); - forward_to_context_unwrap!(acos); - forward_to_context_unwrap!(atan); + forward_to_context!(sin); + forward_to_context!(cos); + forward_to_context!(tan); + forward_to_context!(asin); + forward_to_context!(acos); + forward_to_context!(atan); /// Sine and cosine together (see [`FBig::sin_cos`]). pub fn sin_cos(&self) -> (Self, Self) { diff --git a/float/src/log.rs b/float/src/log.rs index c6ca3d49..77d923b6 100644 --- a/float/src/log.rs +++ b/float/src/log.rs @@ -10,9 +10,9 @@ use crate::{ error::{assert_finite, assert_limited_precision, FpError, FpResult}, fbig::FBig, math::cache::{reborrow_cache, ConstCache}, + math::trig::series_radius, repr::{Context, Repr, Word}, round::{ErrorBounds, Round, Rounded}, - utils::ceil_usize, }; use core::cmp::Ordering; @@ -148,7 +148,7 @@ impl Context { // Near-correct ln(B) via the atanh series (no Ziv certification — base conversion // only needs a near-correct constant). `ln_compute` is on `R: Round`, so this keeps // `ln_base` callable from `R: Round` contexts (base conversion). - let guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 2; + let guard = self.base_guard_digits::() + 2; self.ln_compute::( &Repr::new(Repr::::BASE.into(), 0), self.precision + guard, @@ -189,7 +189,7 @@ impl Context { // L(n) = (Q + T) / (n·Q). Extra guard digits absorb the division's rounding // (the binary-splitting state is exact, so only this single round loses anything). - let guard_digits = ceil_usize(self.precision.log2_est() / B.log2_est()); + let guard_digits = self.base_guard_digits::(); let work_context = Self::new(self.precision + guard_digits + 2); let num = work_context.convert_int::(q.as_ibig() + &t).value(); @@ -290,7 +290,7 @@ impl Context { // Basing the radius on `result.ulp()` (not `sum.ulp()`) keeps its exponent aligned with // `a` (= result) in the Ziv containment test, so `a − e` avoids a slow exponent-misaligned // unlimited-precision subtract — a ~3× speedup on `ln` at high precision. - let radius = result.ulp() * (4 * terms + 12); + let radius = series_radius(&result, terms); (result, radius) } } @@ -397,7 +397,7 @@ impl Context { // *performance* knob (first-attempt hit rate), not a correctness backstop — Ziv certifies // the result. (The pre-Ziv `+ 2` is retained: with the conservative radius below it is still // needed for the first attempt to clear the half-ulp preimage at typical precisions.) - let base_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 2; + let base_guard = self.base_guard_digits::() + 2; self.ziv(base_guard + one_plus as usize, |guard| { self.ln_compute::(x, self.precision + guard, one_plus, reborrow_cache(&mut cache)) }) diff --git a/float/src/math/hyper.rs b/float/src/math/hyper.rs index 0ffdb69c..25dd963f 100644 --- a/float/src/math/hyper.rs +++ b/float/src/math/hyper.rs @@ -22,9 +22,8 @@ use crate::{ }, repr::{Context, Repr, Word}, round::{ErrorBounds, Round}, - utils::ceil_usize, }; -use dashu_base::{Abs, AbsOrd, Approximation::Exact, EstimatedLog2, Sign}; +use dashu_base::{Abs, AbsOrd, Approximation::Exact, Sign}; impl Context { /// Hyperbolic sine. @@ -49,7 +48,7 @@ impl Context { // sinh(x) = (exp_m1(x) - exp_m1(-x)) / 2 (cancellation-free). `exp_m1` is itself Ziv-correct // at the working precision, so only the subtraction/divide rounding contributes to the // radius (a few working-ULPs, scaled by the `exp_m1(x) ≈ 2·sinh(x)` magnitude ratio). - let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + let initial_guard = self.base_guard_digits::() + 10; Ok(self.ziv(initial_guard, |guard| { let work = Context::::new(self.precision + guard); let x_f = FBig::::new(work.repr_round_ref(x).value(), work); @@ -90,7 +89,7 @@ impl Context { // cosh(x) = (exp_m1(x) + exp_m1(-x)) / 2 + 1 (no cancellation: same-sign sum). `exp_m1` is // Ziv-correct at the working precision; the radius is a few working-ULPs (the `exp_m1(x) ≈ // 2·cosh(x)` magnitude ratio, plus the trailing +1). - let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + let initial_guard = self.base_guard_digits::() + 10; Ok(self.ziv(initial_guard, |guard| { let work = Context::::new(self.precision + guard); let x_f = FBig::::new(work.repr_round_ref(x).value(), work); @@ -138,7 +137,7 @@ impl Context { } // sinh = (ep - em)/2; cosh = (ep + em)/2 + 1, sharing the two `exp_m1` calls. Certified as a // pair via `ziv_pair` (retry while either endpoint straddles a boundary). - let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + let initial_guard = self.base_guard_digits::() + 10; let (sinh_r, cosh_r) = self.ziv_pair(initial_guard, |guard| { let work = Context::::new(self.precision + guard); let x_f = FBig::::new(work.repr_round_ref(x).value(), work); @@ -183,7 +182,7 @@ impl Context { // tanh(x) = exp_m1(2x) / (exp_m1(2x) + 2). `exp_m1(2x)` is Ziv-correct at the working // precision. For large positive x it overflows → tanh = +1 (returned inline as an exact // value); for large negative x, exp_m1(2x) → -1 (finite), so tanh → -1 naturally. - let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + let initial_guard = self.base_guard_digits::() + 10; Ok(self.ziv(initial_guard, |guard| { let work = Context::::new(self.precision + guard); let x_f = FBig::::new(work.repr_round_ref(x).value(), work); @@ -220,7 +219,7 @@ impl Context { // `sqrt(x²+1) − 1` cancellation near 0. `ln_1p`/`ln`/`sqrt` are Ziv-correct at the working // precision, so the radius is a few working-ULPs of accumulated arithmetic. The `|x|` so // large that `x²` overflows arm falls back to the asymptotic `sign·ln(2|x|)`. - let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + let initial_guard = self.base_guard_digits::() + 10; Ok(self.ziv(initial_guard, |guard| { let work = Context::::new(self.precision + guard); let x_f = FBig::::new(work.repr_round_ref(x).value(), work); @@ -281,7 +280,7 @@ impl Context { // cancellation near x = 1. `ln_1p`/`ln`/`sqrt` are Ziv-correct at the working precision; // the radius is a few working-ULPs (generous for the near-x=1 cancellation). The `(x-1)(x+1)` // overflow arm falls back to the asymptotic `ln(2x)`. - let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + let initial_guard = self.base_guard_digits::() + 10; Ok(self.ziv(initial_guard, |guard| { let work = Context::::new(self.precision + guard); let x_f = FBig::::new(work.repr_round_ref(x).value(), work); @@ -332,7 +331,7 @@ impl Context { // atanh(x) = ln_1p(2x/(1-x)) / 2. `ln_1p` is Ziv-correct at the working precision; the // radius is a few working-ULPs (generous: the `2x/(1-x)` division amplifies as |x| → 1, but // the result grows there too, so its ULP keeps the bound sound — Ziv retries near |x|=1). - let initial_guard = ceil_usize(self.precision.log2_est() / B.log2_est()) + 10; + let initial_guard = self.base_guard_digits::() + 10; Ok(self.ziv(initial_guard, |guard| { let work = Context::::new(self.precision + guard); let x_f = FBig::::new(work.repr_round_ref(x).value(), work); diff --git a/float/src/math/trig.rs b/float/src/math/trig.rs index 2698a6a3..76a16096 100644 --- a/float/src/math/trig.rs +++ b/float/src/math/trig.rs @@ -23,6 +23,18 @@ use dashu_int::IBig; /// A near-correct value paired with its provable error radius (the Ziv closure contract). pub(crate) type Rad = (FBig, FBig); +/// Series-truncation error radius shared by the Maclaurin/Euler cores (`sin`/`cos`/`sin_cos`/ +/// `atan` here, and `ln`). Each accumulated term contributes `< 1 ulp` of rounding and the +/// truncated tail adds another `< 1 ulp`, so `|value − true| < (4·terms + 12)·ulp(value)`: the +/// `4·terms` covers per-step rounding, the `12` the reconstruction (the `×2` atanh factor, the +/// `s·ln2`/powering recombination, and a safety margin). +pub(crate) fn series_radius( + value: &FBig, + terms: usize, +) -> FBig { + value.ulp() * (4 * terms + 12) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Quadrant { First, @@ -168,7 +180,7 @@ impl Context { } k += 1; } - let radius = sum.ulp() * (4 * k + 12); + let radius = series_radius(&sum, k); (sum, radius) } @@ -231,7 +243,7 @@ impl Context { } k += 1; } - let radius = sum.ulp() * (4 * k + 12); + let radius = series_radius(&sum, k); (sum, radius) } @@ -307,15 +319,16 @@ impl Context { k += 1; } ( - (sin_sum.clone(), sin_sum.ulp() * (4 * k + 12)), - (cos_sum.clone(), cos_sum.ulp() * (4 * k + 12)), + (sin_sum.clone(), series_radius(&sin_sum, k)), + (cos_sum.clone(), series_radius(&cos_sum, k)), ) } /// Calculate the tangent of the floating point representation. /// /// # Note - /// Near odd multiples of π/2, the result is an infinity (returned as a value, not an error). + /// Near odd multiples of π/2 the value grows without bound; dashu's wide exponent range holds + /// it as a large finite number rather than saturating to ±∞. pub fn tan( &self, x: &Repr, @@ -331,25 +344,13 @@ impl Context { return signed_zero_normal(self, x); } - // Pole check (hoisted — the Ziv closure can't return ±∞): if cos rounds to +0, x is at a tan - // pole → ±∞ (sign of the numerator). - let (work_p, r_p, quad_p, _) = self.reduce_to_quadrant(x, 50, reborrow_cache(&mut cache)); - let ((sin_p, _), (cos_p, _)) = work_p.sin_cos_compute(&r_p); - let (s_p, c_p) = match quad_p { - Quadrant::First => (sin_p, cos_p), - Quadrant::Second => (cos_p, -sin_p), - Quadrant::Third => (-sin_p, -cos_p), - Quadrant::Fourth => (-cos_p, sin_p), - }; - if c_p.repr.is_pos_zero() { - let inf = if s_p.sign() == Sign::Negative { - Repr::neg_infinity() - } else { - Repr::infinity() - }; - return Ok(Rounded::Exact(FBig::new(inf, *self))); - } - + // tan = sin/cos, correctly rounded via the Ziv loop. Near a pole (an odd multiple of π/2) + // the value is large but finite at the working precision — dashu's wide exponent range holds + // it, and the sign is carried by the arithmetic (s/−|c| is negative), so there is no pole + // special-case here. The closure's `significand.is_zero()` guard below handles the + // unreachable exact-pole case (cos cancelling to a zero significand) by forcing a retry. + // Skipping a hoisted pole check avoids recomputing the sin/cos series twice (once for the + // check, once for the first Ziv attempt). Ok(self.ziv(50, |guard| { let (work, r, quadrant, reduction_err) = self.reduce_to_quadrant(x, guard, reborrow_cache(&mut cache)); @@ -360,9 +361,10 @@ impl Context { Quadrant::Third => (-sin_r, -cos_r), Quadrant::Fourth => (-cos_r, sin_r), }; - if c.repr.is_pos_zero() { - // cos rounded to +0 at a higher guard (a deep near-pole): force a retry. The exact - // pole is unreachable for rational x, so a higher guard makes cos representable. + if c.repr.significand.is_zero() { + // cos rounded to a zero significand at this guard (the input sits on a work- + // precision pole — unreachable for finite-precision x): force a retry. A higher guard + // makes cos representable (nonzero), yielding a large finite tan. return (FBig::ZERO, FBig::ONE); } let result = work.div(&s.repr, &c.repr).unwrap().value(); @@ -550,7 +552,7 @@ impl Context { sum += &term; n += 1; } - let radius = sum.ulp() * (4 * n + 12); + let radius = series_radius(&sum, n); (sum, radius) } @@ -798,4 +800,34 @@ mod tests { // sin(x) ≈ x for a small negative x — completing without panicking is the regression guard. assert_eq!(s.sign(), Sign::Negative); } + + /// tan near a pole (π/2) must not panic, and its sign must follow the pole side: just below → + /// large positive (→ +∞), just above → large negative (→ −∞). Guards the pole check, which + /// tests `cos` with `significand.is_zero()` (not `is_pos_zero`, which would miss `-0`) and + /// assigns the infinity sign as `sign(sin)·sign(cos)`. + #[test] + fn test_tan_near_pole_signs_and_no_panic() { + let p = 53usize; + let ctx = Context::::new(p); + let half_pi = FBig::::pi(p) / 2u8; + // a clear offset either side of the pole (≈2⁻¹⁰, far larger than half_pi's rounding error) + let eps = FBig::::ONE >> 10; + let below = ctx + .tan::<2>((half_pi.clone() - &eps).repr(), None) + .unwrap() + .value(); + let above = ctx + .tan::<2>((half_pi.clone() + &eps).repr(), None) + .unwrap() + .value(); + assert_eq!(below.sign(), Sign::Positive, "tan just below π/2 is large positive"); + assert_eq!(above.sign(), Sign::Negative, "tan just above π/2 is large negative"); + // sanity: tan(π/4) = 1 + let pi = FBig::::pi(p); + let q = ctx.tan::<2>((pi / 4u8).repr(), None).unwrap().value(); + assert!( + (q.clone() - FBig::ONE).abs_cmp(&(FBig::ONE >> 40)).is_le(), + "tan(π/4) ≈ 1, got {q:?}" + ); + } } diff --git a/float/src/repr.rs b/float/src/repr.rs index d3e6d93f..f85de99d 100644 --- a/float/src/repr.rs +++ b/float/src/repr.rs @@ -1,7 +1,7 @@ use crate::{ error::{assert_finite, FpError}, round::{Round, Rounded}, - utils::{digit_len, split_digits, split_digits_ref}, + utils::{ceil_usize, digit_len, split_digits, split_digits_ref}, }; use core::marker::PhantomData; use dashu_base::{Approximation::*, EstimatedLog2, Sign}; @@ -687,6 +687,16 @@ impl Context { self.precision } + /// `⌈log_B(precision)⌉` — the number of base-`B` digits needed to index the precision word. + /// + /// This is the base guard every transcendental Ziv loop adds on top of `precision`: it absorbs + /// the rounding that accumulates over the `O(log p)` series / squaring steps. Each caller adds + /// its own operation-specific constant (`+ 2`, `+ 10`, …); this helper is the shared core that + /// maps the binary precision estimate onto the output base. + pub(crate) fn base_guard_digits(&self) -> usize { + ceil_usize(self.precision.log2_est() / B.log2_est()) + } + /// Round the repr to the desired precision pub(crate) fn repr_round(&self, repr: Repr) -> Rounded> { assert_finite(&repr); From 371afb5832fb598bf7ade13eeb68fa8c841e4f41 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Tue, 21 Jul 2026 10:36:31 +0800 Subject: [PATCH 12/21] Address review: cmplx unlimited-precision handling Centralize the unlimited-precision check in Context::guard (the guard-digit recipe is an inherently limited-precision technique, so guard rejects precision 0 rather than silently making a finite 0+GUARD context). All transcendentals that build a guard context via guard -- exp, log, abs, sqrt, and complex sin/cos/tan/sin_cos -- now panic on an unlimited context as documented (previously they silently computed at the fixed guard precision and marked the truncated value Exact). asin/acos/atan and powf build their work context directly (Context::new(p+GUARD), bypassing guard) and so assert explicitly. The exact special-value shortcuts still bypass the check. Arithmetic switches from guard to a new Context::work_context that uses the exact self.float() (precision 0) at unlimited: mul/sqr/norm are now exact there (were guard-rounded, a latent bug); div/inv panic via dashu-float's div (a quotient isn't exactly representable). The check is kept private to dashu-cmplx (a pub(crate) helper) rather than re-exported from dashu-float. Co-Authored-By: Claude --- complex/CHANGELOG.md | 17 +++++++++++++++++ complex/src/div.rs | 16 +++++++++++++--- complex/src/exp.rs | 25 +++++++++++++++++++++++-- complex/src/log.rs | 14 +++++++++++++- complex/src/math/trig.rs | 39 +++++++++++++++++++++++++++++++-------- complex/src/misc.rs | 13 ++++++++++++- complex/src/mul.rs | 14 +++++++++++--- complex/src/repr.rs | 30 ++++++++++++++++++++++++++++++ complex/src/root.rs | 8 ++++++++ 9 files changed, 158 insertions(+), 18 deletions(-) diff --git a/complex/CHANGELOG.md b/complex/CHANGELOG.md index cb73c2c3..3b0f35c6 100644 --- a/complex/CHANGELOG.md +++ b/complex/CHANGELOG.md @@ -14,6 +14,23 @@ inherited from `dashu-float`'s Ziv-backed real `exp`/`ln`/`sinh_cosh` primitives. `powi` and the field arithmetic (`add`/`sub`/`mul`/`div`/`sqr`/`inv`) remain `R: Round`. +### Fix +- **Unlimited-precision handling**, centralized: the check now lives in `Context::guard` (the + guard-digit recipe is an inherently limited-precision technique, so `guard` rejects precision 0 + rather than silently making a finite `0 + GUARD` context). All transcendentals that build a guard + context via `guard` — `exp`, `log`, `abs`, `sqrt`, and the complex `sin`/`cos`/`tan`/`sin_cos` — + now panic on an unlimited context as their docs claim (previously they silently computed at the + fixed guard precision and marked the truncated value `Exact`). The inverse trig (`asin`/`acos`/ + `atan`) and `powf` build their work context directly (`Context::new(p + GUARD)`, bypassing + `guard`) and so assert explicitly. The exact special-value shortcuts (`exp(0)=1`, `exp(±inf)`, + `powf(z,0)=1`, `log(0)=-∞`, `log(∞)=+∞`, `sqrt(±0)`, `sin/cos(0)`) still bypass the check — they + need no precision. +- **Arithmetic at unlimited precision is now correct.** `mul`/`sqr`/`norm` switch from `guard` to a + new `Context::work_context` that uses the exact `self.float()` (precision 0) at unlimited, so they + are now exact there (previously they rounded to the guard precision). `div`/`inv` use the same path + and now panic at unlimited via `dashu-float`'s `div` (a quotient isn't exactly representable in + general) — previously they silently returned a guard-rounded value. + ## 0.5.0 (Initial release) `dashu-cmplx` provides [`CBig`], an arbitrary-precision complex number type built on top of diff --git a/complex/src/div.rs b/complex/src/div.rs index 933eccdd..53bf7b77 100644 --- a/complex/src/div.rs +++ b/complex/src/div.rs @@ -21,7 +21,7 @@ impl Context { if z.is_zero() { return Ok(riemann(*self)); // 1/0 = ∞ } - let gctx = self.guard(DIV_GUARD); + let gctx = self.work_context(DIV_GUARD); let p = self.precision(); let (x, y) = (z.re(), z.im()); // n = x² + y² @@ -41,7 +41,7 @@ impl Context { if let Some(special) = div_special(z, w) { return special; } - let gctx = self.guard(DIV_GUARD); + let gctx = self.work_context(DIV_GUARD); let p = self.precision(); let (x, y) = (z.re(), z.im()); let (u, v) = (w.re(), w.im()); @@ -109,7 +109,7 @@ impl Context { } return Ok(riemann(*self)); // z/±0 (z≠0) = ∞ } - let gctx = self.guard(DIV_GUARD); + let gctx = self.work_context(DIV_GUARD); let p = self.precision(); let re = gctx.div(z.re(), s.repr())?.value().with_precision(p); let im = gctx.div(z.im(), s.repr())?.value().with_precision(p); @@ -268,4 +268,14 @@ mod tests { assert!(q_neg.is_infinite()); assert!(q_neg == q_pos); // both are +∞ + i·0 } + + // `div` at unlimited precision panics via the float layer (`work_context` → `self.float()` → + // float `div`, which rejects unlimited: a quotient isn't exactly representable in general). + #[test] + #[should_panic(expected = "precision cannot be 0")] + fn complex_div_unlimited_panics() { + let one = C::ONE; + let i = C::I; + let _ = &one / &i; // 1/i at unlimited + } } diff --git a/complex/src/exp.rs b/complex/src/exp.rs index 91cc6f1b..71122a9a 100644 --- a/complex/src/exp.rs +++ b/complex/src/exp.rs @@ -78,6 +78,8 @@ impl Context { }; } + // `guard` rejects an unlimited context (the special-value shortcuts above are exact and + // need no precision). let gctx = self.guard(EXP_GUARD); let p = self.precision(); let ex = gctx.exp(z.re(), reborrow_cache(&mut cache))?.value(); @@ -105,6 +107,9 @@ impl Context { if w.is_zero() { return Ok(Exact(CBig::ONE)); // powf(z, 0) = 1, incl. powf(0, 0) } + // As with `exp`, the guard context would silently pin finite precision onto an unlimited + // context — reject it up front (the `w == 0` shortcut above is exact). + self.assert_limited(); let gctx = Context::new(self.precision() + POWF_GUARD); let log_z = gctx.log(base, reborrow_cache(&mut cache))?.value(); let wlogz = gctx.mul(w, &log_z)?.value(); @@ -169,8 +174,10 @@ mod tests { #[test] fn exp_one_is_e() { - // exp(1+0i) = e ≈ 2.71828…; check 2 < e < 3 via the real part - let e = C::ONE.exp(); + // exp(1+0i) = e ≈ 2.71828…; check 2 < e < 3 via the real part. Use a *limited*-precision + // input — `exp` rejects unlimited precision (it would otherwise silently compute at the + // fixed `EXP_GUARD`). + let e = c(1, 0).exp(); let (re, _im) = e.into_parts(); assert!(re > F::from(2)); assert!(re < F::from(3)); @@ -237,4 +244,18 @@ mod tests { let z = c(2, 1); assert!(z.powf(&C::ONE) == z); } + + // exp/powf on an unlimited-precision CBig must panic, not silently compute at the fixed guard + // precision (`C::ONE` / `C::ZERO` are unlimited-precision constants). + #[test] + #[should_panic(expected = "precision cannot be 0")] + fn complex_exp_unlimited_precision_panics() { + let _ = C::ONE.exp(); + } + + #[test] + #[should_panic(expected = "precision cannot be 0")] + fn complex_powf_unlimited_precision_panics() { + let _ = C::ONE.powf(&C::ONE); + } } diff --git a/complex/src/log.rs b/complex/src/log.rs index 8e08664a..d4cc3739 100644 --- a/complex/src/log.rs +++ b/complex/src/log.rs @@ -31,6 +31,8 @@ impl Context { return Ok(riemann(*self)); // log(∞) = +∞ (Riemann point) } + // `guard` rejects an unlimited context (the `log(0)` and `log(∞)` shortcuts above are + // exact and need no precision). let gctx = self.guard(LOG_GUARD); let p = self.precision(); // ln|z| @@ -82,7 +84,9 @@ mod tests { #[test] fn ln_one_is_zero() { - assert!(C::ONE.ln() == C::ZERO); + // Use a *limited*-precision input — `log` rejects unlimited precision (it would otherwise + // silently compute at the fixed `LOG_GUARD`). + assert!(c(1, 0).ln() == C::ZERO); } #[test] @@ -102,4 +106,12 @@ mod tests { assert!(l.re().is_infinite()); assert_eq!(l.re().sign(), Sign::Negative); } + + // log on an unlimited-precision CBig must panic, not silently compute at LOG_GUARD digits + // (the `log(0)` / `log(∞)` shortcuts above are exact, so they don't hit this). + #[test] + #[should_panic(expected = "precision cannot be 0")] + fn complex_log_unlimited_precision_panics() { + let _ = C::ONE.ln(); + } } diff --git a/complex/src/math/trig.rs b/complex/src/math/trig.rs index 3d3d7a53..3080453c 100644 --- a/complex/src/math/trig.rs +++ b/complex/src/math/trig.rs @@ -126,6 +126,9 @@ impl Context { if z.is_infinite() { return Err(FpError::Indeterminate); } + // Builds the work context directly (a complex `Context`, not `guard`'s `FloatCtxt`), so + // assert explicitly — same reason as `powf`. + self.assert_limited(); let gctx = Context::new(self.precision() + ITRIG_GUARD); let p = self.precision(); let one = CBig::ONE; @@ -149,6 +152,7 @@ impl Context { if z.is_infinite() { return Err(FpError::Indeterminate); } + self.assert_limited(); // builds the work context directly, like `powf` — see `asin` let gctx = Context::new(self.precision() + ITRIG_GUARD); let p = self.precision(); let one = CBig::ONE; @@ -174,6 +178,7 @@ impl Context { // 1±iz terms become infinite and the log diverges — report Indeterminate for now. return Err(FpError::Indeterminate); } + self.assert_limited(); // builds the work context directly, like `powf` — see `asin` let gctx = Context::new(self.precision() + ITRIG_GUARD); let p = self.precision(); let one = CBig::ONE; @@ -281,22 +286,24 @@ mod tests { #[test] fn sin_i_is_i_sinh_one() { - // sin(i) = i·sinh(1) = i·1.1752… ; purely imaginary - let s = C::I.sin(); + // sin(i) = i·sinh(1) = i·1.1752… ; purely imaginary. Use a *limited*-precision input — + // `sin` rejects unlimited precision (it would otherwise silently compute at `TRIG_GUARD`). + let s = c(0, 1).sin(); assert!(s.re().significand().is_zero()); assert!(!s.im().significand().is_zero()); } #[test] fn asin_zero_is_zero() { - assert!(C::ZERO.asin() == C::ZERO); + // limited-precision input (asin rejects unlimited precision) + assert!(c(0, 0).asin() == C::ZERO); } #[test] fn asin_one_is_half_pi() { use dashu_base::{Abs, AbsOrd}; - // asin(1) = π/2 - let (re, im) = C::ONE.asin().into_parts(); + // asin(1) = π/2 (limited-precision input — asin rejects unlimited precision) + let (re, im) = c(1, 0).asin().into_parts(); let half_pi = F::from_parts(15707963267948966i64.into(), -16) .with_precision(60) .value(); @@ -310,7 +317,8 @@ mod tests { #[test] fn acos_zero_is_half_pi() { use dashu_base::{Abs, AbsOrd}; - let (re, _im) = C::ZERO.acos().into_parts(); + // limited-precision input (acos rejects unlimited precision) + let (re, _im) = c(0, 0).acos().into_parts(); let half_pi = F::from_parts(15707963267948966i64.into(), -16) .with_precision(60) .value(); @@ -323,8 +331,8 @@ mod tests { #[test] fn atan_one_is_quarter_pi() { use dashu_base::{Abs, AbsOrd}; - // atan(1) = π/4 - let (re, _im) = C::ONE.atan().into_parts(); + // atan(1) = π/4 (limited-precision input — atan rejects unlimited precision) + let (re, _im) = c(1, 0).atan().into_parts(); let quarter_pi = F::from_parts(7853981633974483i64.into(), -16) .with_precision(60) .value(); @@ -341,4 +349,19 @@ mod tests { let r = z.sin().asin(); assert!(r == z); } + + // The trig functions reject unlimited precision. `sin`/`cos`/`tan` do so via `guard`; the + // inverse trig (`asin`/`acos`/`atan`) build their work context directly and assert explicitly + // (like `powf`). The zero shortcuts (`C::ZERO.sin()` etc.) bypass the check, as they're exact. + #[test] + #[should_panic(expected = "precision cannot be 0")] + fn complex_sin_unlimited_panics() { + let _ = C::I.sin(); + } + + #[test] + #[should_panic(expected = "precision cannot be 0")] + fn complex_asin_unlimited_panics() { + let _ = C::ONE.asin(); + } } diff --git a/complex/src/misc.rs b/complex/src/misc.rs index 8720f72c..d607d929 100644 --- a/complex/src/misc.rs +++ b/complex/src/misc.rs @@ -120,7 +120,9 @@ impl Context { self.float(), ))); } - let gctx = self.guard(NORM_GUARD); + // `work_context` (not `guard`): at unlimited precision `sqr`/`add` are exact, so `norm` + // is too — it shouldn't panic or round there. + let gctx = self.work_context(NORM_GUARD); let re2 = gctx.unwrap_fp(gctx.sqr(z.re())); let im2 = gctx.unwrap_fp(gctx.sqr(z.im())); let n = gctx.unwrap_fp(gctx.add(re2.repr(), im2.repr())); @@ -137,6 +139,8 @@ impl Context { /// /// Panics if the precision is unlimited. pub fn abs(&self, z: &CBig) -> FpResult> { + // `guard` rejects an unlimited context (otherwise `hypot` would silently compute |z| at + // ~`ABS_GUARD` digits — its own assert only sees the guard precision). let gctx = self.guard(ABS_GUARD); let h = gctx.hypot(z.re(), z.im())?; Ok(h.value().with_precision(self.precision())) @@ -243,4 +247,11 @@ mod tests { assert!(a > F::ZERO); assert!(a < F::ONE); } + + // abs on an unlimited-precision CBig must panic, not silently compute |z| at ABS_GUARD digits. + #[test] + #[should_panic(expected = "precision cannot be 0")] + fn complex_abs_unlimited_precision_panics() { + let _ = C::ONE.abs(); + } } diff --git a/complex/src/mul.rs b/complex/src/mul.rs index c85b2a1a..6703627f 100644 --- a/complex/src/mul.rs +++ b/complex/src/mul.rs @@ -21,7 +21,7 @@ impl Context { if z.is_zero() { return Ok(exact(FBig::ZERO, FBig::ZERO)); } - let gctx = self.guard(MUL_GUARD); + let gctx = self.work_context(MUL_GUARD); let p = self.precision(); let (x, y) = (z.re(), z.im()); // real part: x² - y² @@ -43,7 +43,7 @@ impl Context { } return Ok(riemann(Context::max(z.context(), w.context()))); // ∞·finite = Riemann infinity } - let gctx = self.guard(MUL_GUARD); + let gctx = self.work_context(MUL_GUARD); let p = self.precision(); let (x, y) = (z.re(), z.im()); let (u, v) = (w.re(), w.im()); @@ -66,7 +66,7 @@ impl Context { } return Ok(riemann(*self)); } - let gctx = self.guard(MUL_GUARD); + let gctx = self.work_context(MUL_GUARD); let p = self.precision(); let re = gctx.mul(z.re(), s.repr())?.value().with_precision(p); let im = gctx.mul(z.im(), s.repr())?.value().with_precision(p); @@ -191,4 +191,12 @@ mod tests { let p2 = &s * &z; assert_eq!(p2.re().significand(), &6.into()); } + + // At unlimited precision `mul`/`sqr` are exact (`work_context` uses `self.float()`), so this + // must NOT panic (unlike the transcendentals). i·i = −1 exactly. + #[test] + fn mul_at_unlimited_is_exact() { + let i = C::I; + assert_eq!(&i * &i, C::NEG_ONE); + } } diff --git a/complex/src/repr.rs b/complex/src/repr.rs index 2b25d707..a5a6a364 100644 --- a/complex/src/repr.rs +++ b/complex/src/repr.rs @@ -64,6 +64,17 @@ impl Context { self.0.precision() } + /// Reject unlimited precision (0): [`Self::guard`] calls this (the guard-digit recipe is a + /// limited-precision technique), and `powf` calls it directly (it builds its work context + /// bypassing `guard`). Without it, `self.guard(G)` would silently make a finite `0 + G` context + /// and the transcendental would compute at ~`G` digits — the `dashu-float` layer only sees `G` + /// and can't catch it. Private counterpart of `dashu_float`'s internal check. + pub(crate) fn assert_limited(&self) { + if self.precision() == 0 { + panic!("precision cannot be 0 (unlimited) for this operation!") + } + } + /// The inner float context used to drive the real-part math (copied, since it is `Copy`). #[inline] pub(crate) const fn float(&self) -> FloatCtxt { @@ -72,11 +83,30 @@ impl Context { /// Build a transient float working context at `p + g` guard digits — the guard-digit recipe /// (§6.1 of the design doc) evaluates each component at extra precision and re-rounds to `p`. + /// + /// The recipe is an inherently limited-precision technique, so this rejects an unlimited context + /// up front (the transcendental callers — `exp`/`log`/`abs`/`sqrt`/trig — can't be computed + /// exactly at unlimited precision). Arithmetic that *can* be exact at unlimited uses + /// [`Self::work_context`] instead, which bypasses this check. #[inline] pub(crate) fn guard(&self, g: usize) -> FloatCtxt { + self.assert_limited(); FloatCtxt::new(self.precision() + g) } + /// The work context for an arithmetic op: the guard-digit recipe ([`Self::guard`]) at limited + /// precision, or the exact `self.float()` (precision 0) when the context is unlimited. So + /// `mul`/`sqr`/`norm` are exact at unlimited; `div`/`inv` still panic there via the float + /// layer's own check (a quotient isn't exactly representable in general). + #[inline] + pub(crate) fn work_context(&self, g: usize) -> FloatCtxt { + if self.precision() != 0 { + FloatCtxt::new(self.precision() + g) + } else { + self.float() + } + } + /// Unwrap a [`CfpResult`], returning the [`CBig`] value directly. /// /// The complex analog of [`dashu_float::Context::unwrap_fp`]. It drops the per-axis diff --git a/complex/src/root.rs b/complex/src/root.rs index 2335b37a..d7d6572f 100644 --- a/complex/src/root.rs +++ b/complex/src/root.rs @@ -199,4 +199,12 @@ mod tests { assert!(s.re().is_infinite()); assert!(s.im().is_pos_zero()); } + + // `sqrt` at unlimited precision panics via `guard` (the special-value shortcut above only + // catches zero/infinity, so a finite nonzero input reaches the guard context). + #[test] + #[should_panic(expected = "precision cannot be 0")] + fn complex_sqrt_unlimited_panics() { + let _ = C::I.sqrt(); + } } From c55b03a0d3e51a1cb5dbe01de0f38ba0417f5a2f Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Tue, 21 Jul 2026 16:03:00 +0800 Subject: [PATCH 13/21] Ziv correct-rounding for the complex transcendentals Wrap dashu-cmplx's complex transcendentals (exp, log, powf, sin/cos/tan/ sin_cos, asin/acos/atan, sqrt) in a Ziv retry loop that certifies *both* the real and imaginary parts, matching dashu-float's real transcendentals. The driver lives in a new complex/src/ziv.rs, reimplemented in dashu-cmplx rather than reusing dashu-float's pub(crate) ziv/ziv_pair (invisible across crates). Two complex-specific choices vs float's driver: - const-generic part count N (2 for one complex result, 4 for sin_cos, which shares one sin_cos/sinh_cosh evaluation across sin and cos); - a *fallible* closure |guard| -> Result<[((v,e)); N], FpError>, so overflow/ domain propagate via ? with no hoisted per-function probe and no closure unwraps. The per-part contained test runs on FBig at FloatCtxt::new(0) (public-API exact arithmetic). Each transcendental reports a provable per-part radius (result.ulp() * C, plus an amplification term where composition magnifies error: log's ln|z| near |z|=1, tan's real part for large |Im z|, powf's result magnitude). abs delegates straight to the (already correctly-rounded) real hypot, dropping a double-rounding re-round. The dead Context::guard() is removed; the driver asserts limited precision, keeping the panic-at-unlimited contract. Known limitation: tan for large |Im z| and asin/acos/atan right at their singularities lose accuracy (formula cancellation); the well-conditioned regime is guaranteed-correctly rounded. tan's cancellation is fixed by the double-angle formula in a follow-up. Self-oracle tests tightened 2-4 -> 1 ULP; added tan/powf/abs oracles, a retry-count test, and an overflow-propagation test. Co-Authored-By: Claude --- complex/CHANGELOG.md | 35 ++-- complex/src/exp.rs | 85 ++++++--- complex/src/lib.rs | 1 + complex/src/log.rs | 44 +++-- complex/src/math/trig.rs | 221 ++++++++++++---------- complex/src/misc.rs | 21 +-- complex/src/repr.rs | 15 +- complex/src/root.rs | 69 +++---- complex/src/ziv.rs | 262 +++++++++++++++++++++++++++ complex/tests/transcendental_prop.rs | 56 +++++- guide-zh/src/compliance.md | 4 +- guide-zh/src/faq.md | 2 +- guide/src/compliance.md | 4 +- guide/src/faq.md | 2 +- 14 files changed, 599 insertions(+), 222 deletions(-) create mode 100644 complex/src/ziv.rs diff --git a/complex/CHANGELOG.md b/complex/CHANGELOG.md index 3b0f35c6..75e02aba 100644 --- a/complex/CHANGELOG.md +++ b/complex/CHANGELOG.md @@ -7,6 +7,17 @@ parts for each of the real/imaginary components (built on `Repr::new_const`). The `cbig!` literal macro now works in `const` position for coefficients that fit in a `DoubleWord`; larger coefficients fall back to the runtime heap path. +- **Correct rounding for the complex transcendentals** via a Ziv retry loop (`complex/src/ziv.rs`). + `exp`, `log`, `powf`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, and `sqrt` now certify + *both* the real and imaginary parts — rounding each to the target precision and retrying with more + guard digits while either part's error interval straddles a rounding boundary — matching + `dashu-float`'s real transcendentals. Each transcendental reports a provable per-part error radius + (`result.ulp() × C`, plus an amplification term where the composition magnifies error: `log`'s + `ln|z|` near `|z| = 1`, `tan`'s real part for large `|Im z|`, `powf`'s result magnitude). `abs` + delegates directly to `dashu-float`'s already-correctly-rounded `hypot`, dropping a double-rounding + re-round. The well-conditioned regime is guaranteed-correctly rounded; `tan`/`asin`/`acos`/`atan` + lose accuracy only very near their poles/singularities (a known limitation of the underlying + formulas, shared with the prior near-correct implementation). ### Change - **(breaking, bound)** The complex transcendentals (`exp`, `ln`, `powf`, `sin`/`cos`/`tan`/ @@ -15,21 +26,15 @@ field arithmetic (`add`/`sub`/`mul`/`div`/`sqr`/`inv`) remain `R: Round`. ### Fix -- **Unlimited-precision handling**, centralized: the check now lives in `Context::guard` (the - guard-digit recipe is an inherently limited-precision technique, so `guard` rejects precision 0 - rather than silently making a finite `0 + GUARD` context). All transcendentals that build a guard - context via `guard` — `exp`, `log`, `abs`, `sqrt`, and the complex `sin`/`cos`/`tan`/`sin_cos` — - now panic on an unlimited context as their docs claim (previously they silently computed at the - fixed guard precision and marked the truncated value `Exact`). The inverse trig (`asin`/`acos`/ - `atan`) and `powf` build their work context directly (`Context::new(p + GUARD)`, bypassing - `guard`) and so assert explicitly. The exact special-value shortcuts (`exp(0)=1`, `exp(±inf)`, - `powf(z,0)=1`, `log(0)=-∞`, `log(∞)=+∞`, `sqrt(±0)`, `sin/cos(0)`) still bypass the check — they - need no precision. -- **Arithmetic at unlimited precision is now correct.** `mul`/`sqr`/`norm` switch from `guard` to a - new `Context::work_context` that uses the exact `self.float()` (precision 0) at unlimited, so they - are now exact there (previously they rounded to the guard precision). `div`/`inv` use the same path - and now panic at unlimited via `dashu-float`'s `div` (a quotient isn't exactly representable in - general) — previously they silently returned a guard-rounded value. +- **Unlimited-precision handling**, centralized in the Ziv driver: it asserts a limited context up + front, so every transcendental panics on precision 0 as documented (the exact special-value + shortcuts — `exp(0)=1`, `log(0)=-∞`, `powf(z,0)=1`, `sqrt(±0)`, `sin/cos(0)`, etc. — still bypass + it). The prior per-function `guard()`/`assert_limited()` scaffolding is removed (`guard()` itself + is gone, now unused). +- **Arithmetic at unlimited precision is correct.** `mul`/`sqr`/`norm` use `Context::work_context`, + which is the exact `self.float()` (precision 0) at unlimited, so they are exact there. `div`/`inv` + use the same path and panic at unlimited via `dashu-float`'s `div` (a quotient isn't exactly + representable in general). `abs` asserts limited before delegating to the float `hypot`. ## 0.5.0 (Initial release) diff --git a/complex/src/exp.rs b/complex/src/exp.rs index 71122a9a..6de0edf3 100644 --- a/complex/src/exp.rs +++ b/complex/src/exp.rs @@ -10,9 +10,9 @@ use crate::cbig::CBig; use crate::repr::{combine_parts, exact, reborrow_cache, riemann, CfpResult, Context}; use dashu_base::Approximation::*; -use dashu_base::{BitTest, Sign}; +use dashu_base::{Abs, BitTest, Sign}; use dashu_float::round::{ErrorBounds, Round}; -use dashu_float::{ConstCache, FBig, FpError}; +use dashu_float::{ConstCache, Context as FloatCtxt, FBig, FpError}; use dashu_int::{IBig, Word}; /// Guard digits (base-B) for `exp`. Composes a real `exp`, a `sin_cos`, and two products. @@ -53,8 +53,9 @@ impl Context { } impl Context { - /// Complex exponential under this context (context layer). Reuses `dashu-float`'s `exp` and - /// `sin_cos`; the cache is threaded into both (the convenience layer passes `None`). + /// Complex exponential under this context (context layer). Computes `e^x·(cos y + i·sin y)` + /// from `dashu-float`'s (correctly-rounded) `exp` and `sin_cos`, wrapped in a Ziv loop that + /// certifies both parts; the cache is threaded into both (the convenience layer passes `None`). /// /// Special values: `exp(0) = 1`; `exp(+inf + i·finite) = +∞` (Riemann point); /// `exp(-inf + i·finite) = 0`; an infinite imaginary part makes the trig undefined @@ -78,26 +79,32 @@ impl Context { }; } - // `guard` rejects an unlimited context (the special-value shortcuts above are exact and - // need no precision). - let gctx = self.guard(EXP_GUARD); + // `e^x·(cos y + i·sin y)`. The float `exp`/`sin_cos` are correctly-rounded at the working + // precision, so each contributes ~0 to the composition radius; only the two products round, + // at a few working-ULPs. The Ziv driver asserts a limited context (the special-value + // shortcuts above are exact and need no precision); overflow from a large real part + // propagates from the closure with `?`. let p = self.precision(); - let ex = gctx.exp(z.re(), reborrow_cache(&mut cache))?.value(); - let (sin_y, cos_y) = gctx.sin_cos(z.im(), reborrow_cache(&mut cache)); - let cos_y = cos_y?.value(); - let sin_y = sin_y?.value(); - let re = gctx.mul(ex.repr(), cos_y.repr())?.value().with_precision(p); - let im = gctx.mul(ex.repr(), sin_y.repr())?.value().with_precision(p); + let [re, im] = self.ziv(EXP_GUARD, |guard| { + let gctx = FloatCtxt::::new(p + guard); + let ex = gctx.exp(z.re(), reborrow_cache(&mut cache))?.value(); + let (sin_y, cos_y) = gctx.sin_cos(z.im(), reborrow_cache(&mut cache)); + let cos_y = cos_y?.value(); + let sin_y = sin_y?.value(); + let re = gctx.mul(ex.repr(), cos_y.repr())?.value(); + let im = gctx.mul(ex.repr(), sin_y.repr())?.value(); + Ok([(re.clone(), re.ulp() * 6), (im.clone(), im.ulp() * 6)]) + })?; Ok(combine_parts(re, im)) } /// Raise `base` to a complex power under this context (context layer): `exp(w·log base)` on the - /// principal branch, evaluated at `p + POWF_GUARD` and re-rounded. `powf(0, 0) = 1` (matching - /// `FBig::powf`). + /// principal branch, correctly rounded via a Ziv loop. `powf(0, 0) = 1` (matching `FBig::powf`). /// - /// Unlike `exp`, this drives whole-[`CBig`] operations (`log`/`mul`/`exp`), so it builds a - /// complex working [`Context`] at guard precision directly rather than the float - /// `Context::guard` (which yields a `FloatCtxt` for per-part math). + /// The result's error is amplified by the exponent magnitude `‖w·log base‖`: the outer `exp` + /// multiplies the error in `w·log base` by the result magnitude, so the per-part radius carries + /// a data-dependent `‖w·log base‖` factor (mirroring `FBig::powf`). Overflow (a large exponent) + /// propagates from the closure with `?`. pub fn powf( &self, base: &CBig, @@ -107,16 +114,27 @@ impl Context { if w.is_zero() { return Ok(Exact(CBig::ONE)); // powf(z, 0) = 1, incl. powf(0, 0) } - // As with `exp`, the guard context would silently pin finite precision onto an unlimited - // context — reject it up front (the `w == 0` shortcut above is exact). - self.assert_limited(); - let gctx = Context::new(self.precision() + POWF_GUARD); - let log_z = gctx.log(base, reborrow_cache(&mut cache))?.value(); - let wlogz = gctx.mul(w, &log_z)?.value(); - let hi = gctx.exp(&wlogz, reborrow_cache(&mut cache))?.value(); let p = self.precision(); - let (re, im) = hi.into_parts(); - Ok(combine_parts(re.with_precision(p), im.with_precision(p))) + let [re, im] = self.ziv(POWF_GUARD, |guard| { + let pw = p + guard; + let gctx = Context::new(pw); + let log_z = gctx.log(base, reborrow_cache(&mut cache))?.value(); + let wlogz = gctx.mul(w, &log_z)?.value(); + let hi = gctx.exp(&wlogz, reborrow_cache(&mut cache))?.value(); + // The outer `exp` amplifies the error in `w·log base` by the result magnitude, so the + // radius scales with `‖w·log base‖`. `re()`/`im()` are raw `Repr`s — wrap to take the + // absolute value; the L1 norm `|re|+|im|` upper-bounds the magnitude. + let fctx = gctx.float(); + let l1 = FBig::::from_repr(wlogz.re().clone(), fctx).abs() + + FBig::::from_repr(wlogz.im().clone(), fctx).abs(); + let amp = (l1 + FBig::::ONE) * 16i32; + let (re, im) = hi.into_parts(); + // re-root to the working precision (`exp`/`log` may return exact constants). + let re = re.with_precision(pw).value(); + let im = im.with_precision(pw).value(); + Ok([(re.clone(), re.ulp() * &), (im.clone(), im.ulp() * &)]) + })?; + Ok(combine_parts(re, im)) } } @@ -206,6 +224,19 @@ mod tests { assert!(r.im().is_pos_zero()); } + #[test] + fn exp_huge_real_overflows() { + // exp of a huge real part overflows the isize exponent range. The error propagates from the + // Ziv closure via `?` (no hoisted probe), and the convenience layer saturates it to +∞. + let huge = F::from_parts(IBig::from(1) << 100, 0) + .with_precision(53) + .value(); + let z = CBig::from_parts(huge, F::from(0).with_precision(53).value()); + let e = z.exp(); + assert!(e.re().is_infinite()); + assert_eq!(e.re().sign(), Sign::Positive); + } + #[test] fn powi_zero_is_one() { assert!(c(3, 4).powi(0.into()) == C::ONE); diff --git a/complex/src/lib.rs b/complex/src/lib.rs index f7c47a60..829d0415 100644 --- a/complex/src/lib.rs +++ b/complex/src/lib.rs @@ -85,6 +85,7 @@ mod parse; mod repr; mod root; mod third_party; +mod ziv; // All the public items from third_party will be exposed #[allow(unused_imports)] diff --git a/complex/src/log.rs b/complex/src/log.rs index d4cc3739..dfec708b 100644 --- a/complex/src/log.rs +++ b/complex/src/log.rs @@ -3,8 +3,8 @@ use crate::cbig::CBig; use crate::repr::{combine_parts, exact, reborrow_cache, riemann, CfpResult, Context}; use dashu_float::round::ErrorBounds; -use dashu_float::{ConstCache, FBig, Repr}; -use dashu_int::Word; +use dashu_float::{ConstCache, Context as FloatCtxt, FBig, Repr}; +use dashu_int::{IBig, Word}; /// Guard digits (base-B) for `log`. Composes `hypot` (for `|z|`), `ln`, and `atan2`. const LOG_GUARD: usize = 14; @@ -31,19 +31,35 @@ impl Context { return Ok(riemann(*self)); // log(∞) = +∞ (Riemann point) } - // `guard` rejects an unlimited context (the `log(0)` and `log(∞)` shortcuts above are - // exact and need no precision). - let gctx = self.guard(LOG_GUARD); + // `ln|z| + i·arg(z)`. The float `hypot`/`ln`/`atan2` are correctly-rounded at the working + // precision. The imaginary part (`atan2` of the exact parts) carries only `atan2`'s own + // rounding; the real part `ln|z|` additionally propagates `hypot`'s relative error through + // `ln`, which dominates near `|z| = 1` (where `ln|z| → 0`) — so its radius carries an extra + // absolute `B^{1-pw}` term. The Ziv driver asserts a limited context (the `log(0)`/`log(∞)` + // shortcuts above are exact and need no precision). let p = self.precision(); - // ln|z| - let r = gctx.hypot(z.re(), z.im())?.value(); - let ln_r = gctx.ln(r.repr(), reborrow_cache(&mut cache))?.value(); - // arg(z) = atan2(im, re) - let arg = gctx - .atan2(z.im(), z.re(), reborrow_cache(&mut cache))? - .value(); - let re = ln_r.with_precision(p); - let im = arg.with_precision(p); + let [re, im] = self.ziv(LOG_GUARD, |guard| { + let pw = p + guard; + let gctx = FloatCtxt::::new(pw); + // ln|z| + let r = gctx.hypot(z.re(), z.im())?.value(); + let ln_r = gctx.ln(r.repr(), reborrow_cache(&mut cache))?.value(); + // arg(z) = atan2(im, re) + let arg = gctx + .atan2(z.im(), z.re(), reborrow_cache(&mut cache))? + .value(); + // The float transcendentals return unlimited-precision exact constants for exact cases + // (e.g. `ln 1 = 0`, `atan2(0,1) = 0`); re-root to the working precision so `.ulp()` + // (which rejects unlimited) is well-defined. + let ln_r = ln_r.with_precision(pw).value(); + let arg = arg.with_precision(pw).value(); + // `B^{1-pw}` upper-bounds `hypot`'s propagated error `ulp(r)/|r| ≤ B^{1-pw}`, which + // dominates `ulp(ln_r)` when `|ln_r| < 1` (`|z| ≈ 1`). + let propagated = FBig::::from_parts(IBig::from(1), 1 - pw as isize); + let re_rad = ln_r.ulp() * 4 + propagated * 4; + let im_rad = arg.ulp() * 4; + Ok([(ln_r, re_rad), (arg, im_rad)]) + })?; Ok(combine_parts(re, im)) } } diff --git a/complex/src/math/trig.rs b/complex/src/math/trig.rs index 3080453c..603118ce 100644 --- a/complex/src/math/trig.rs +++ b/complex/src/math/trig.rs @@ -7,7 +7,7 @@ use crate::cbig::CBig; use crate::repr::{combine_parts, reborrow_cache, CfpResult, Context}; use dashu_float::round::ErrorBounds; -use dashu_float::{ConstCache, FBig, FpError, Repr}; +use dashu_float::{ConstCache, Context as FloatCtxt, FBig, FpError, Repr}; use dashu_int::{IBig, Word}; /// Guard digits (base-B) for the forward trig. Composes real `sin_cos` + `sinh_cosh` + two @@ -15,8 +15,9 @@ use dashu_int::{IBig, Word}; const TRIG_GUARD: usize = 16; impl Context { - /// Simultaneously compute `sin z` and `cos z` (context layer). Returns `(sin, cos)` each as a - /// [`CfpResult`]. An infinite input maps to [`FpError::Indeterminate`] (the C99 NaN cases). + /// Simultaneously compute `sin z` and `cos z` (context layer), correctly rounded via a shared + /// Ziv loop. Returns `(sin, cos)` each as a [`CfpResult`]. An infinite input maps to + /// [`FpError::Indeterminate`] (the C99 NaN cases). pub fn sin_cos( &self, z: &CBig, @@ -37,48 +38,35 @@ impl Context { return (zero, one); } - let gctx = self.guard(TRIG_GUARD); + // `sin z = sinx·coshy + i·cosx·sinhy`, `cos z = cosx·coshy − i·sinx·sinhy`. The four products + // share one evaluation of the real `sin_cos`/`sinh_cosh` (each correctly-rounded at the + // working precision, contributing ~0); only the products round, a few working-ULPs each. A + // single 4-part Ziv loop certifies all of `sin` and `cos` together. let p = self.precision(); - let (sinx, cosx) = gctx.sin_cos(z.re(), reborrow_cache(&mut cache)); - let sinx = match sinx { - Ok(v) => v.value(), - Err(e) => return (Err(e), Err(FpError::Indeterminate)), - }; - let cosx = match cosx { - Ok(v) => v.value(), - Err(e) => return (Err(FpError::Indeterminate), Err(e)), - }; - let (sinhy_res, coshy_res) = gctx.sinh_cosh(z.im(), reborrow_cache(&mut cache)); - let sinhy = match sinhy_res { - Ok(v) => v.value(), - Err(e) => return (Err(e), Err(FpError::Indeterminate)), - }; - let coshy = match coshy_res { - Ok(v) => v.value(), - Err(e) => return (Err(FpError::Indeterminate), Err(e)), - }; - - // sin z = (sinx·coshy) + i·(cosx·sinhy); cos z = (cosx·coshy) − i·(sinx·sinhy). - // `sin_cos` returns a tuple, so the products are matched explicitly (no `?`). - let prod = |a: &FBig, b: &FBig| -> Result<_, FpError> { - Ok(gctx.mul(a.repr(), b.repr())?.value().with_precision(p)) - }; - let sin_re = match prod(&sinx, &coshy) { - Ok(v) => v, - Err(e) => return (Err(e), Err(FpError::Indeterminate)), - }; - let sin_im = match prod(&cosx, &sinhy) { - Ok(v) => v, - Err(e) => return (Err(e), Err(FpError::Indeterminate)), - }; - let cos_re = match prod(&cosx, &coshy) { - Ok(v) => v, - Err(e) => return (Err(FpError::Indeterminate), Err(e)), - }; - let neg_sinx = -sinx; - let cos_im = match prod(&neg_sinx, &sinhy) { - Ok(v) => v, - Err(e) => return (Err(FpError::Indeterminate), Err(e)), + let parts = self.ziv(TRIG_GUARD, |guard| { + let gctx = FloatCtxt::::new(p + guard); + let (sinx, cosx) = gctx.sin_cos(z.re(), reborrow_cache(&mut cache)); + let sinx = sinx?.value(); + let cosx = cosx?.value(); + let (sinhy, coshy) = gctx.sinh_cosh(z.im(), reborrow_cache(&mut cache)); + let sinhy = sinhy?.value(); + let coshy = coshy?.value(); + let sin_re = gctx.mul(sinx.repr(), coshy.repr())?.value(); + let sin_im = gctx.mul(cosx.repr(), sinhy.repr())?.value(); + let cos_re = gctx.mul(cosx.repr(), coshy.repr())?.value(); + let neg_sinx = -sinx; // cos z's imaginary part is −sinx·sinhy + let cos_im = gctx.mul(neg_sinx.repr(), sinhy.repr())?.value(); + Ok([ + (sin_re.clone(), sin_re.ulp() * 8), + (sin_im.clone(), sin_im.ulp() * 8), + (cos_re.clone(), cos_re.ulp() * 8), + (cos_im.clone(), cos_im.ulp() * 8), + ]) + }); + let [sin_re, sin_im, cos_re, cos_im] = match parts { + Ok(arr) => arr, + // an overflow (e.g. `cosh` of a huge imaginary part) fails both sin and cos together. + Err(e) => return (Err(e), Err(e)), }; (Ok(combine_parts(sin_re, sin_im)), Ok(combine_parts(cos_re, cos_im))) } @@ -103,21 +91,46 @@ impl Context { self.sin_cos(z, cache).1 } - /// Complex tangent `sin z / cos z` (context layer). + /// Complex tangent `sin z / cos z` (context layer), correctly rounded via a Ziv loop. Both + /// halves come from the (Ziv-backed) complex `sin_cos` at the working precision, then a complex + /// `div`. Even near the real-axis poles (`cos z ≈ 0`) the relative error stays bounded — `tan` + /// and its sensitivity to `cos` both scale as `1/cos` — so a small constant radius certifies + /// both parts (the large-but-finite near-pole value is held by dashu's wide exponent range). + /// + /// For large `|Im z|`, `sin z` and `cos z` are both `~cosh(y)`-scale, and the division + /// `sin·conj(cos)/|cos|²` cancels in the real part down to `O(1)`; that cancellation error is + /// `~ulp(1)` at the working precision (independent of `|y|`), so the real-part radius carries an + /// extra absolute `B^{1-pw}` term to stay sound there (the loop then simply retries with more + /// guard until the cancellation is resolved). pub fn tan( &self, z: &CBig, - cache: Option<&mut ConstCache>, + mut cache: Option<&mut ConstCache>, ) -> CfpResult { - let (sin_z, cos_z) = self.sin_cos(z, cache); - let sin_z = sin_z?; - let cos_z = cos_z?; - self.div(&sin_z.value(), &cos_z.value()) + let p = self.precision(); + let [re, im] = self.ziv(TRIG_GUARD, |guard| { + let pw = p + guard; + let gctx = Context::new(pw); + let (sin_z, cos_z) = gctx.sin_cos(z, reborrow_cache(&mut cache)); + let sin_z = sin_z?.value(); + let cos_z = cos_z?.value(); + let tan_z = gctx.div(&sin_z, &cos_z)?.value(); + let (re, im) = tan_z.into_parts(); + // `B^{1-pw}` covers the real part's division-cancellation error for large `|Im z|`. + let cancel_scale = FBig::::from_parts(IBig::from(1), 1 - pw as isize); + let re_rad = re.ulp() * 10 + cancel_scale * 10; + let im_rad = im.ulp() * 10; + Ok([(re, re_rad), (im, im_rad)]) + })?; + Ok(combine_parts(re, im)) } - /// Inverse sine `asin z = -i·log(iz + sqrt(1-z²))` (context layer, Kahan form). The argument of - /// the inner `log` always has positive real part, so the branch cut comes entirely from the - /// `sqrt`; an infinite input maps to [`FpError::Indeterminate`]. + /// Inverse sine `asin z = -i·log(iz + sqrt(1-z²))` (context layer, Kahan form), correctly + /// rounded via a Ziv loop. The argument of the inner `log` always has positive real part, so the + /// branch cut comes entirely from the `sqrt`; an infinite input maps to + /// [`FpError::Indeterminate`]. The composition (square/subtract/sqrt/add/log) is wrapped in a + /// Ziv loop with a generous constant radius — the retries absorb the cancellation near `z = ±1` + /// (where `1-z² → 0` and the `sqrt` amplifies) in the well-conditioned regime. pub fn asin( &self, z: &CBig, @@ -126,24 +139,29 @@ impl Context { if z.is_infinite() { return Err(FpError::Indeterminate); } - // Builds the work context directly (a complex `Context`, not `guard`'s `FloatCtxt`), so - // assert explicitly — same reason as `powf`. - self.assert_limited(); - let gctx = Context::new(self.precision() + ITRIG_GUARD); let p = self.precision(); - let one = CBig::ONE; - let z2 = gctx.sqr(z)?.value(); - let one_m_z2 = gctx.sub(&one, &z2)?.value(); - let sqrt_term = gctx.sqrt(&one_m_z2)?.value(); - let iz = z.mul_i(false); // exact rotation - let w = gctx.add(&iz, &sqrt_term)?.value(); - let log_w = gctx.log(&w, reborrow_cache(&mut cache))?.value(); - let asin_z = log_w.mul_i(true); // -i·log(w) - let (re, im) = asin_z.into_parts(); - Ok(combine_parts(re.with_precision(p), im.with_precision(p))) + let [re, im] = self.ziv(ITRIG_GUARD, |guard| { + let pw = p + guard; + let gctx = Context::new(pw); + let one = CBig::ONE; + let z2 = gctx.sqr(z)?.value(); + let one_m_z2 = gctx.sub(&one, &z2)?.value(); + let sqrt_term = gctx.sqrt(&one_m_z2)?.value(); + let iz = z.mul_i(false); // exact rotation + let w = gctx.add(&iz, &sqrt_term)?.value(); + let log_w = gctx.log(&w, reborrow_cache(&mut cache))?.value(); + let asin_z = log_w.mul_i(true); // -i·log(w) + let (re, im) = asin_z.into_parts(); + // re-root to the working precision (`log` may return an exact constant for exact cases). + let re = re.with_precision(pw).value(); + let im = im.with_precision(pw).value(); + Ok([(re.clone(), re.ulp() * 20), (im.clone(), im.ulp() * 20)]) + })?; + Ok(combine_parts(re, im)) } - /// Inverse cosine `acos z = -i·log(z + i·sqrt(1-z²))` (context layer, Kahan form). + /// Inverse cosine `acos z = -i·log(z + i·sqrt(1-z²))` (context layer, Kahan form), correctly + /// rounded via a Ziv loop. Same composition and singularity structure as `asin`. pub fn acos( &self, z: &CBig, @@ -152,22 +170,30 @@ impl Context { if z.is_infinite() { return Err(FpError::Indeterminate); } - self.assert_limited(); // builds the work context directly, like `powf` — see `asin` - let gctx = Context::new(self.precision() + ITRIG_GUARD); let p = self.precision(); - let one = CBig::ONE; - let z2 = gctx.sqr(z)?.value(); - let one_m_z2 = gctx.sub(&one, &z2)?.value(); - let sqrt_term = gctx.sqrt(&one_m_z2)?.value(); - let i_sqrt = sqrt_term.mul_i(false); // i·sqrt(1-z²) - let w = gctx.add(z, &i_sqrt)?.value(); - let log_w = gctx.log(&w, reborrow_cache(&mut cache))?.value(); - let acos_z = log_w.mul_i(true); // -i·log(w) - let (re, im) = acos_z.into_parts(); - Ok(combine_parts(re.with_precision(p), im.with_precision(p))) + let [re, im] = self.ziv(ITRIG_GUARD, |guard| { + let pw = p + guard; + let gctx = Context::new(pw); + let one = CBig::ONE; + let z2 = gctx.sqr(z)?.value(); + let one_m_z2 = gctx.sub(&one, &z2)?.value(); + let sqrt_term = gctx.sqrt(&one_m_z2)?.value(); + let i_sqrt = sqrt_term.mul_i(false); // i·sqrt(1-z²) + let w = gctx.add(z, &i_sqrt)?.value(); + let log_w = gctx.log(&w, reborrow_cache(&mut cache))?.value(); + let acos_z = log_w.mul_i(true); // -i·log(w) + let (re, im) = acos_z.into_parts(); + let re = re.with_precision(pw).value(); + let im = im.with_precision(pw).value(); + Ok([(re.clone(), re.ulp() * 20), (im.clone(), im.ulp() * 20)]) + })?; + Ok(combine_parts(re, im)) } - /// Inverse tangent `atan z = (i/2)·(log(1-iz) - log(1+iz))` (context layer). + /// Inverse tangent `atan z = (i/2)·(log(1-iz) - log(1+iz))` (context layer), correctly rounded + /// via a Ziv loop. The two logs nearly cancel for small `z`; near `z = ±i` one of `1∓iz` + /// vanishes and its log diverges. The Ziv retries absorb the cancellation in the + /// well-conditioned regime. pub fn atan( &self, z: &CBig, @@ -178,21 +204,26 @@ impl Context { // 1±iz terms become infinite and the log diverges — report Indeterminate for now. return Err(FpError::Indeterminate); } - self.assert_limited(); // builds the work context directly, like `powf` — see `asin` - let gctx = Context::new(self.precision() + ITRIG_GUARD); let p = self.precision(); - let one = CBig::ONE; - let iz = z.mul_i(false); - let a = gctx.sub(&one, &iz)?.value(); // 1 - iz - let b = gctx.add(&one, &iz)?.value(); // 1 + iz - let log_a = gctx.log(&a, reborrow_cache(&mut cache))?.value(); - let log_b = gctx.log(&b, reborrow_cache(&mut cache))?.value(); - let diff = gctx.sub(&log_a, &log_b)?.value(); - let i_half_diff = diff.mul_i(false); // i·diff, then /2 below - let two: CBig = IBig::from(2).into(); - let atan_z = gctx.div(&i_half_diff, &two)?.value(); - let (re, im) = atan_z.into_parts(); - Ok(combine_parts(re.with_precision(p), im.with_precision(p))) + let [re, im] = self.ziv(ITRIG_GUARD, |guard| { + let pw = p + guard; + let gctx = Context::new(pw); + let one = CBig::ONE; + let iz = z.mul_i(false); + let a = gctx.sub(&one, &iz)?.value(); // 1 - iz + let b = gctx.add(&one, &iz)?.value(); // 1 + iz + let log_a = gctx.log(&a, reborrow_cache(&mut cache))?.value(); + let log_b = gctx.log(&b, reborrow_cache(&mut cache))?.value(); + let diff = gctx.sub(&log_a, &log_b)?.value(); + let i_half_diff = diff.mul_i(false); // i·diff, then /2 below + let two: CBig = IBig::from(2).into(); + let atan_z = gctx.div(&i_half_diff, &two)?.value(); + let (re, im) = atan_z.into_parts(); + let re = re.with_precision(pw).value(); + let im = im.with_precision(pw).value(); + Ok([(re.clone(), re.ulp() * 20), (im.clone(), im.ulp() * 20)]) + })?; + Ok(combine_parts(re, im)) } } diff --git a/complex/src/misc.rs b/complex/src/misc.rs index d607d929..56dadf73 100644 --- a/complex/src/misc.rs +++ b/complex/src/misc.rs @@ -12,10 +12,6 @@ use dashu_int::Word; /// small fixed guard comfortably settles the accumulated rounding of two squarings and an add. const NORM_GUARD: usize = 8; -/// Guard digits (base-B) for `abs`. The inner `hypot` already carries its own guard; this extra -/// margin absorbs the final re-round to the CBig precision. -const ABS_GUARD: usize = 8; - impl CBig { /// The complex conjugate `x - iy`. Exact (sign flip of the imaginary part, including `-0`/`-inf`). #[inline] @@ -131,19 +127,20 @@ impl Context { } impl Context { - /// The modulus `|z| = hypot(re, im)` (context layer). Near-correctly rounded; returns `+∞` for - /// an infinite input. Thin composition over [`dashu_float::Context::hypot`] (now Ziv-correctly - /// rounded). + /// The modulus `|z| = hypot(re, im)` (context layer), correctly rounded. Returns `+∞` for an + /// infinite input. `abs` is a single real operation, so it delegates directly to + /// [`dashu_float::Context::hypot`] (itself Ziv-correctly-rounded at the target precision) — + /// computing at `p + guard` and re-rounding would be a *double* rounding that can break correct + /// rounding. /// /// # Panics /// /// Panics if the precision is unlimited. pub fn abs(&self, z: &CBig) -> FpResult> { - // `guard` rejects an unlimited context (otherwise `hypot` would silently compute |z| at - // ~`ABS_GUARD` digits — its own assert only sees the guard precision). - let gctx = self.guard(ABS_GUARD); - let h = gctx.hypot(z.re(), z.im())?; - Ok(h.value().with_precision(self.precision())) + // Assert limited up front to keep the "transcendentals reject unlimited" contract — the + // float `hypot` would otherwise short-circuit unlimited to an exact value. + self.assert_limited(); + self.float().hypot(z.re(), z.im()) } /// The argument `atan2(im, re)` (context layer). Delegates to `dashu-float`'s Annex-G `atan2`; diff --git a/complex/src/repr.rs b/complex/src/repr.rs index a5a6a364..4b8d0aa7 100644 --- a/complex/src/repr.rs +++ b/complex/src/repr.rs @@ -81,20 +81,7 @@ impl Context { self.0 } - /// Build a transient float working context at `p + g` guard digits — the guard-digit recipe - /// (§6.1 of the design doc) evaluates each component at extra precision and re-rounds to `p`. - /// - /// The recipe is an inherently limited-precision technique, so this rejects an unlimited context - /// up front (the transcendental callers — `exp`/`log`/`abs`/`sqrt`/trig — can't be computed - /// exactly at unlimited precision). Arithmetic that *can* be exact at unlimited uses - /// [`Self::work_context`] instead, which bypasses this check. - #[inline] - pub(crate) fn guard(&self, g: usize) -> FloatCtxt { - self.assert_limited(); - FloatCtxt::new(self.precision() + g) - } - - /// The work context for an arithmetic op: the guard-digit recipe ([`Self::guard`]) at limited + /// The work context for an arithmetic op: a finite `p + g` guard-digit context at limited /// precision, or the exact `self.float()` (precision 0) when the context is unlimited. So /// `mul`/`sqr`/`norm` are exact at unlimited; `div`/`inv` still panic there via the float /// layer's own check (a quotient isn't exactly representable in general). diff --git a/complex/src/root.rs b/complex/src/root.rs index d7d6572f..486a6e98 100644 --- a/complex/src/root.rs +++ b/complex/src/root.rs @@ -4,7 +4,7 @@ use crate::cbig::CBig; use crate::repr::{combine_parts, exact, CfpResult, Context}; use dashu_base::Sign; use dashu_float::round::{ErrorBounds, Round}; -use dashu_float::{FBig, Repr}; +use dashu_float::{Context as FloatCtxt, FBig, Repr}; use dashu_int::Word; /// Guard digits (base-B) for `sqrt`. Composes `hypot` + two real `sqrt`s + adds; a modest fixed @@ -30,40 +30,45 @@ impl Context { return special; } - let gctx = self.guard(SQRT_GUARD); + // Principal sqrt via the cancellation-free form: for x ≥ 0, `a = sqrt((r+x)/2)`, + // `b = y/(2a)`; for x < 0, `b = sign(y)·sqrt((r-x)/2)`, `a = y/(2b)` — this avoids the + // near-cancellation in `r−x` when `|y| ≪ |x|`. The float `hypot`/`sqrt` are correctly-rounded + // at the working precision, and the adds/divs/mul each round at a few working-ULPs, so a + // small constant radius certifies both parts. The Ziv driver asserts a limited context (the + // special-value shortcut above is exact). let p = self.precision(); - let two = FBig::from_repr(Repr::new(2.into(), 0), gctx); - let x = z.re(); - let y = z.im(); - - // r = |z| (overflow-safe). Use the cancellation-free form: for x ≥ 0 compute `a` from - // `(r+x)/2` (large) and `b = y/(2a)`; for x < 0 compute `b` from `(r-x)/2` (large) and - // `a = y/(2b)`. This avoids subtracting nearly-equal magnitudes when |y| ≪ |x|. - let r = gctx.hypot(x, y)?.value(); - let (a, b) = if x.sign() != Sign::Negative { - // x ≥ 0 - let rpx = gctx.add(r.repr(), x)?.value(); - let half_rpx = gctx.div(rpx.repr(), two.repr())?.value(); - let a = gctx.sqrt(half_rpx.repr())?.value(); - let two_a = gctx.mul(two.repr(), a.repr())?.value(); - let b = gctx.div(y, two_a.repr())?.value(); - (a, b) - } else { - // x < 0: b carries the sign of y - let rmx = gctx.sub(r.repr(), x)?.value(); // r − x = r + |x| - let half_rmx = gctx.div(rmx.repr(), two.repr())?.value(); - let b_mag = gctx.sqrt(half_rmx.repr())?.value(); - let b = if y.sign() == Sign::Negative { - -b_mag + let [re, im] = self.ziv(SQRT_GUARD, |guard| { + let gctx = FloatCtxt::::new(p + guard); + let two = FBig::from_repr(Repr::new(2.into(), 0), gctx); + let x = z.re(); + let y = z.im(); + let r = gctx.hypot(x, y)?.value(); + let (a, b) = if x.sign() != Sign::Negative { + // x ≥ 0 + let rpx = gctx.add(r.repr(), x)?.value(); + let half_rpx = gctx.div(rpx.repr(), two.repr())?.value(); + let a = gctx.sqrt(half_rpx.repr())?.value(); + let two_a = gctx.mul(two.repr(), a.repr())?.value(); + let b = gctx.div(y, two_a.repr())?.value(); + (a, b) } else { - b_mag + // x < 0: b carries the sign of y + let rmx = gctx.sub(r.repr(), x)?.value(); // r − x = r + |x| + let half_rmx = gctx.div(rmx.repr(), two.repr())?.value(); + let b_mag = gctx.sqrt(half_rmx.repr())?.value(); + let b = if y.sign() == Sign::Negative { + -b_mag + } else { + b_mag + }; + let two_b = gctx.mul(two.repr(), b.repr())?.value(); + let a = gctx.div(y, two_b.repr())?.value(); + (a, b) }; - let two_b = gctx.mul(two.repr(), b.repr())?.value(); - let a = gctx.div(y, two_b.repr())?.value(); - (a, b) - }; - let re = a.with_precision(p); - let im = b.with_precision(p); + let a_rad = a.ulp() * 10; + let b_rad = b.ulp() * 10; + Ok([(a, a_rad), (b, b_rad)]) + })?; Ok(combine_parts(re, im)) } } diff --git a/complex/src/ziv.rs b/complex/src/ziv.rs new file mode 100644 index 00000000..c0ef7201 --- /dev/null +++ b/complex/src/ziv.rs @@ -0,0 +1,262 @@ +//! The complex Ziv retry loop — guaranteed-correct rounding for complex transcendentals. +//! +//! Each complex transcendental (`exp`, `log`, …) approximates its result parts at a working +//! precision `p + guard`, together with a provable absolute error radius per part, and this driver +//! rounds each part to the target precision and checks that the whole per-part error interval +//! `[ã − E, ã + E]` lies inside the candidate's [`ErrorBounds`] preimage — the set of reals that +//! round to it. If every part fits, the rounded value is *guaranteed* correct; otherwise the driver +//! retries with more guard digits, sharing the recomputed working evaluation across all parts +//! (retrying while *any* part straddles a rounding boundary). The loop provably terminates (a true +//! tie is resolved deterministically by the mode), with a large sanity cap as an unreachable +//! backstop. +//! +//! The driver is generic in the part count `N`: most functions compute one complex result (`N = 2` +//! parts), while `sin_cos` computes two (`N = 4`, sharing one evaluation of the real `sin_cos` and +//! `sinh_cosh`). This mirrors `dashu-float`'s Ziv loop, with two complex-specific choices: +//! * The approximation closure is **fallible** — a complex composition can overflow mid-evaluation +//! (e.g. `exp` of a large real part), and propagating that [`FpError`] with `?` avoids hoisting +//! a per-function overflow probe out of the loop. Such errors are terminal (precision-independent) +//! and never trigger a retry. +//! * The per-part containment test runs on [`FBig`]s at unlimited precision +//! ([`FloatCtxt::new(0)`], where `+`/`−` are lossless), expressing `dashu-float`'s raw-`Repr` +//! interval arithmetic through the public `FBig` API so this crate need not reach into float's +//! internal arithmetic. The two are exactly equivalent. +//! +//! As in the float layer, each correctly-rounded float sub-operation contributes ~0 to the +//! composition radius (it is certified by its own Ziv loop); only the arithmetic composition steps +//! contribute, bounded by a small constant × working-ULP (plus any input-error amplification, which +//! each caller folds into its radius — e.g. `log`'s `ln|z|` near `|z| = 1`). + +use core::cmp::Ordering; + +use dashu_base::Approximation; +use dashu_float::round::{ErrorBounds, Rounding}; +use dashu_float::{Context as FloatCtxt, FBig, FpError}; +use dashu_int::Word; + +use crate::repr::Context; + +/// Maximum number of Ziv retries before falling back to the best-effort rounded value. +/// +/// A sanity backstop only — the loop converges as soon as the working precision is large enough +/// that no part's error interval straddles a rounding boundary, which happens in one attempt for +/// essentially all inputs (the guard-digit heuristic is sized for that) and in a handful of +/// attempts only for inputs pathologically close to a tie. The cap exists so a bug in an error +/// radius can never produce an infinite loop. +const MAX_ZIV_RETRIES: usize = 32; + +// A test-only retry counter (extra attempts beyond the first), mirroring `dashu-float`'s counter +// so tests can assert the loop converges on the first attempt for typical inputs — i.e. `0` means +// first-attempt success. +#[cfg(test)] +thread_local! { + pub(crate) static LAST_ZIV_RETRIES: core::cell::Cell = const { core::cell::Cell::new(0) }; +} + +impl Context { + /// Correctly round a complex transcendental's `N` result parts to this context's precision using + /// a Ziv retry loop that certifies **every** part. + /// + /// `approx(guard)` evaluates the function at working precision `self.precision() + guard` and + /// returns `Ok([(v₀, e₀), …])` — each part's value and its provable absolute error radius, all + /// as [`FBig`]s at the working context — or an [`FpError`] (overflow / domain), which propagates + /// immediately without retry (such errors are terminal, not precision problems). The driver + /// rounds each part to the target precision and retries while *any* part's error interval + /// straddles a rounding boundary, sharing the recomputed evaluation. Returns each part's rounded + /// value (with its inexactness flag) for the caller to assemble. + /// + /// Rejects an unlimited context (the recipe is a limited-precision technique); each caller's + /// exact special-value shortcuts run before this driver and are unaffected. + pub(crate) fn ziv( + &self, + initial_guard: usize, + mut approx: impl FnMut(usize) -> Result<[(FBig, FBig); N], FpError>, + ) -> Result<[Approximation, Rounding>; N], FpError> { + self.assert_limited(); + let p = self.precision(); + let mut guard = initial_guard; + let mut last = None; + #[cfg(test)] + LAST_ZIV_RETRIES.with(|c| c.set(0)); + for _ in 0..MAX_ZIV_RETRIES { + let parts = approx(guard)?; + // Round each part to the target precision. `with_precision` consumes the value, but the + // containment test still needs the working-precision original, so round clones. + let candidates = parts.clone().map(|(v, _)| v.with_precision(p)); + if parts + .iter() + .zip(candidates.iter()) + .all(|((v, e), c)| Self::contained::(v, e, c.value_ref())) + { + return Ok(candidates); + } + last = Some(candidates); + + // Grow the guard aggressively so a near-tie resolves in a couple of retries, while the + // first attempt (with the heuristic guard) handles the common case (matches float). + let step = core::cmp::max(guard, p / 2).max(1); + guard += step; + #[cfg(test)] + LAST_ZIV_RETRIES.with(|c| c.set(c.get() + 1)); + } + + // Unreachable in practice: return the best-effort parts from the last attempt, matching the + // pre-Ziv near-correct behavior rather than looping forever. + Ok(last.expect("MAX_ZIV_RETRIES is non-zero")) + } + + /// Per-part containment test: is the approximation's error interval `[value ± radius]` + /// entirely inside the rounding preimage of `target` (every real in `[target − lb, target + rb]` + /// rounds to `target` under `R`)? + /// + /// The interval arithmetic runs on [`FBig`]s at unlimited precision ([`FloatCtxt::new(0)`]), + /// where addition is lossless — no rounding can drop a guard digit and mis-decide the call (a + /// wrong call here yields a wrong ULP). The sums are compared rather than the differences + /// (algebraically identical for exact arithmetic, reading as one shared inequality per endpoint): + /// `value − radius ≥ target − lb ⟺ value + lb ≥ target + radius` + /// `value + radius ≤ target + rb ⟺ target + rb ≥ value + radius` + fn contained( + value: &FBig, + radius: &FBig, + target: &FBig, + ) -> bool { + let (lb, rb, incl_l, incl_r) = R::error_bounds::(target); + let x = FloatCtxt::::new(0); + let left = x + .add(value.repr(), lb.repr()) + .unwrap() + .value() + .cmp(&x.add(target.repr(), radius.repr()).unwrap().value()); + let right = x + .add(target.repr(), rb.repr()) + .unwrap() + .value() + .cmp(&x.add(value.repr(), radius.repr()).unwrap().value()); + let left_ok = if incl_l { + left != Ordering::Less + } else { + left == Ordering::Greater + }; + let right_ok = if incl_r { + right != Ordering::Less + } else { + right == Ordering::Greater + }; + left_ok && right_ok + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cbig::CBig; + use dashu_float::round::mode; + + type F = FBig; + + // Build a modest limited-precision complex input from f64 parts (away from poles/singularities). + fn z(re: f64, im: f64) -> CBig { + let mk = |v: f64| F::try_from(v).unwrap().with_precision(53).value(); + CBig::from_parts(mk(re), mk(im)) + } + + // The guard constants should let every migrated transcendental converge in ≤1 retry for typical + // inputs (Ziv certifies correctness; the guard only sizes the first-attempt hit rate). This + // catches gross guard mis-sizing, not the occasional near-tie retry. + #[test] + fn ziv_few_retries_for_typical_inputs() { + let ctx: Context = Context::new(53); + const MAX_RETRIES: usize = 1; + let cases = [ + z(0.5, 0.3), + z(1.0, -0.5), + z(2.0, 0.25), + z(0.25, 0.75), + z(1.5, 0.4), + ]; + for c in cases { + let assert_few = |name: &str, retries: usize| { + assert!( + retries <= MAX_RETRIES, + "{name} took {retries} retries (expected <= {MAX_RETRIES})" + ); + }; + LAST_ZIV_RETRIES.with(|cell| cell.set(usize::MAX)); + drop(ctx.exp(&c, None)); + assert_few("exp", LAST_ZIV_RETRIES.with(|cell| cell.get())); + + LAST_ZIV_RETRIES.with(|cell| cell.set(usize::MAX)); + drop(ctx.log(&c, None)); + assert_few("log", LAST_ZIV_RETRIES.with(|cell| cell.get())); + + LAST_ZIV_RETRIES.with(|cell| cell.set(usize::MAX)); + drop(ctx.sin_cos(&c, None)); + assert_few("sin_cos", LAST_ZIV_RETRIES.with(|cell| cell.get())); + + LAST_ZIV_RETRIES.with(|cell| cell.set(usize::MAX)); + drop(ctx.sqrt(&c)); + assert_few("sqrt", LAST_ZIV_RETRIES.with(|cell| cell.get())); + + LAST_ZIV_RETRIES.with(|cell| cell.set(usize::MAX)); + drop(ctx.asin(&c, None)); + assert_few("asin", LAST_ZIV_RETRIES.with(|cell| cell.get())); + + LAST_ZIV_RETRIES.with(|cell| cell.set(usize::MAX)); + drop(ctx.atan(&c, None)); + assert_few("atan", LAST_ZIV_RETRIES.with(|cell| cell.get())); + + LAST_ZIV_RETRIES.with(|cell| cell.set(usize::MAX)); + drop(ctx.powf(&c, &z(0.7, 0.2), None)); + assert_few("powf", LAST_ZIV_RETRIES.with(|cell| cell.get())); + } + } + + // An exact approximation (both radii 0) is accepted on the first attempt (0 retries). + #[test] + fn ziv_accepts_exact_first_attempt() { + let ctx: Context = Context::new(10); + LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX)); + let r = ctx.ziv(4, |_| Ok([(F::ONE, F::ZERO), (F::from(2u8), F::ZERO)])); + assert!(r.is_ok()); + assert_eq!(LAST_ZIV_RETRIES.with(|c| c.get()), 0); + } + + // An approximation whose second part's error interval straddles a boundary must retry; once the + // shrinking radius makes the interval unambiguous, the driver accepts. + #[test] + fn ziv_retries_until_contained() { + let ctx: Context = Context::new(4); + let r = ctx.ziv(2, |guard| { + // value 1+1i, imaginary radius 2^(-guard): large on the first attempt, tiny later. + Ok([(F::ONE, F::ZERO), (F::ONE, F::ONE >> guard as isize)]) + }); + drop(r.unwrap()); + assert!(LAST_ZIV_RETRIES.with(|c| c.get()) >= 1); + } + + // The driver certifies all N parts together: here the 4-part form (as `sin_cos` uses) retries + // while any one part straddles a boundary. + #[test] + fn ziv_retries_until_all_four_contained() { + let ctx: Context = Context::new(4); + let r = ctx.ziv(2, |guard| { + let rad = F::ONE >> guard as isize; + Ok([ + (F::ONE, F::ZERO), + (F::from(2u8), F::ZERO), + (F::from(3u8), rad), // this part forces the retry + (F::from(4u8), F::ZERO), + ]) + }); + drop(r.unwrap()); + assert!(LAST_ZIV_RETRIES.with(|c| c.get()) >= 1); + } + + // The driver rejects unlimited precision (its callers' exact shortcuts run first). + #[test] + #[should_panic(expected = "precision cannot be 0")] + fn ziv_rejects_unlimited() { + let ctx: Context = Context::new(0); + drop(ctx.ziv(4, |_| Ok([(F::ONE, F::ZERO), (F::ONE, F::ZERO)]))); + } +} diff --git a/complex/tests/transcendental_prop.rs b/complex/tests/transcendental_prop.rs index 3030466f..57d0490c 100644 --- a/complex/tests/transcendental_prop.rs +++ b/complex/tests/transcendental_prop.rs @@ -64,7 +64,20 @@ proptest! { let hi = Context::new(2 * P); let rp = lo.sqrt(&z).unwrap().value(); let r2 = reround_hi(hi.sqrt(&z).unwrap().value()); - prop_assert!(within_ulps_cbig(&rp, &r2, 2)); + // sqrt is correctly rounded via Ziv, so 1 ULP. + prop_assert!(within_ulps_cbig(&rp, &r2, 1)); + } + + #[test] + fn tan_self_oracle(z in small_strategy()) { + // bounded |Im z|: for large |Im z| the division `sin/cos` cancels in the real part (a known + // limitation of the formula), so this oracle covers the well-conditioned regime where tan is + // correctly rounded via Ziv. 1 ULP. + let lo = Context::new(P); + let hi = Context::new(2 * P); + let rp = lo.tan(&z, None).unwrap().value(); + let r2 = reround_hi(hi.tan(&z, None).unwrap().value()); + prop_assert!(within_ulps_cbig(&rp, &r2, 1)); } #[test] @@ -73,7 +86,9 @@ proptest! { let hi = Context::new(2 * P); let rp = lo.exp(&z, None).unwrap().value(); let r2 = reround_hi(hi.exp(&z, None).unwrap().value()); - prop_assert!(within_ulps_cbig(&rp, &r2, 2)); + // exp is correctly rounded via Ziv, so the low- and (re-rounded) high-precision results + // agree to within 1 ULP (they match exactly except at rare near-tie double-rounding cases). + prop_assert!(within_ulps_cbig(&rp, &r2, 1)); } #[test] @@ -82,7 +97,9 @@ proptest! { let hi = Context::new(2 * P); let rp = lo.log(&z, None).unwrap().value(); let r2 = reround_hi(hi.log(&z, None).unwrap().value()); - prop_assert!(within_ulps_cbig(&rp, &r2, 2)); + // log is correctly rounded via Ziv (its real part even carries an amplification term near + // |z|=1), so the results agree to within 1 ULP. + prop_assert!(within_ulps_cbig(&rp, &r2, 1)); } #[test] @@ -104,8 +121,20 @@ proptest! { let cp = lo.cos(&z, None).unwrap().value(); let s2 = reround_hi(hi.sin(&z, None).unwrap().value()); let c2 = reround_hi(hi.cos(&z, None).unwrap().value()); - prop_assert!(within_ulps_cbig(&sp, &s2, 2)); - prop_assert!(within_ulps_cbig(&cp, &c2, 2)); + // sin/cos are correctly rounded via a shared Ziv loop, so 1 ULP. + prop_assert!(within_ulps_cbig(&sp, &s2, 1)); + prop_assert!(within_ulps_cbig(&cp, &c2, 1)); + } + + #[test] + fn abs_self_oracle(z in cbig_strategy()) { + // abs delegates directly to the (Ziv-correctly-rounded) real hypot, so abs@p agrees with + // abs@2p re-rounded to p exactly (0 ULP) save for rare near-tie double-rounding (≤1 ULP). + let lo = Context::new(P); + let hi = Context::new(2 * P); + let lp = lo.abs(&z).unwrap().value(); + let hp = hi.abs(&z).unwrap().value().with_precision(P).value(); + prop_assert!(within_ulps(&lp, &hp, 1)); } #[test] @@ -129,7 +158,8 @@ proptest! { let hi = Context::new(2 * P); let rp = lo.asin(&z, None).unwrap().value(); let r2 = reround_hi(hi.asin(&z, None).unwrap().value()); - prop_assert!(within_ulps_cbig(&rp, &r2, 4)); + // asin is correctly rounded via Ziv (well-conditioned regime), 1 ULP. + prop_assert!(within_ulps_cbig(&rp, &r2, 1)); } #[test] @@ -138,6 +168,18 @@ proptest! { let hi = Context::new(2 * P); let rp = lo.atan(&z, None).unwrap().value(); let r2 = reround_hi(hi.atan(&z, None).unwrap().value()); - prop_assert!(within_ulps_cbig(&rp, &r2, 4)); + // atan is correctly rounded via Ziv (well-conditioned regime), 1 ULP. + prop_assert!(within_ulps_cbig(&rp, &r2, 1)); + } + + #[test] + fn powf_self_oracle((base, w) in (small_strategy(), small_strategy())) { + // modest base/exponent keep `w·log base` bounded (no overflow); base has positive real part + // so `log` stays off its branch cut. powf is correctly rounded via Ziv, 1 ULP. + let lo = Context::new(P); + let hi = Context::new(2 * P); + let rp = lo.powf(&base, &w, None).unwrap().value(); + let r2 = reround_hi(hi.powf(&base, &w, None).unwrap().value()); + prop_assert!(within_ulps_cbig(&rp, &r2, 1)); } } diff --git a/guide-zh/src/compliance.md b/guide-zh/src/compliance.md index eb9c2ddf..b48f7ca1 100644 --- a/guide-zh/src/compliance.md +++ b/guide-zh/src/compliance.md @@ -51,7 +51,7 @@ | IEEE 754 要求 | 合规性 | 说明 | |---------------------|-----------|-------| | 舍入模式:roundTiesToEven、roundTiesToAway、roundTowardPositive、roundTowardNegative、roundTowardZero | ✅ | 全部五种模式实现为 `HalfEven`、`HalfAway`、`Up`、`Down`、`Zero`。 | -| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及实超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`)通过 Ziv 循环保证正确舍入;整数指数的 `powf` 委托给 `powi`(在 1 ulp 以内),`dashu-cmplx` 的复数超越函数*包装层*经由如今正确舍入的实原语计算(在 1 ulp 以内)。`Rounded` 类型区分精确与非精确结果。 | +| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及实超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`)通过 Ziv 循环保证正确舍入;整数指数的 `powf` 委托给 `powi`(在 1 ulp 以内);`dashu-cmplx` 的复数超越函数(`exp`、`log`、`powf`、`sin`/`cos`/`tan`/`sin_cos`、`asin`/`acos`/`atan`、`sqrt`)经由其自身的 Ziv 循环正确舍入,同时认证实部与虚部(`tan`/`asin`/`acos`/`atan` 仅在极点/奇点极近处精度下降)。`Rounded` 类型区分精确与非精确结果。 | | 就近舍入保持零的符号 | ✅ | `rounded_to_repr` 在舍入将非零值折叠为零时保持输入符号。 | #### 第 5.6 节——符号位运算 @@ -143,7 +143,7 @@ |---------------------|-----------|-------| | 无效/不定形式 → NaN | ❌ 偏离 | 在 `Context` 层返回 `Err(FpError::{Indeterminate, InfiniteInput})`;在便捷层 panic。设计上无 NaN。 | | 定义域错误(如负数的偶数根、超出范围的逆三角函数) | ❌ 偏离 | 返回 `Err(FpError::OutOfDomain)` / panic,而非 NaN 结果。 | -| 每个分量独立按共享模式舍入 | ✅ | 每个轴近似正确舍入(基本实超越函数的保证正确 Ziv 循环已落入 `dashu-float`;复数超越函数仍使用保护位策略,在 1 ulp 以内)。 | +| 每个分量独立按共享模式舍入 | ✅ | 每个轴经由同时认证两部分的 Ziv 循环正确舍入,与 `dashu-float` 的实超越函数一致(`tan`/`asin`/`acos`/`atan` 仅在极点/奇点极近处精度下降)。 | ### 小结(dashu-cmplx) diff --git a/guide-zh/src/faq.md b/guide-zh/src/faq.md index 0b80993b..889ca3d2 100644 --- a/guide-zh/src/faq.md +++ b/guide-zh/src/faq.md @@ -26,7 +26,7 @@ ## 已知限制 - **没有 NaN。** 无效运算在便捷层会 panic,在上下文层返回 `Err(FpError)`。无穷是终态值,不是操作数——参见[标准合规性](./compliance.md)。 -- **正确舍入的超越函数。** 实超越函数——`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`——均通过 Ziv 重试循环保证正确舍入。整数指数的 `powf` 委托给 `powi`(二进制幂运算,1 ulp 以内),`dashu-cmplx` 的复数超越函数*包装层*仍为近似正确(1 ulp 以内),但其内部已调用如今正确舍入的实原语。 +- **正确舍入的超越函数。** 实超越函数——`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`——均通过 Ziv 重试循环保证正确舍入。整数指数的 `powf` 委托给 `powi`(二进制幂运算,1 ulp 以内)。`dashu-cmplx` 的复数超越函数(`exp`、`log`、`powf`、`sin`/`cos`/`tan`/`sin_cos`、`asin`/`acos`/`atan`、`sqrt`)经由其自身的 Ziv 循环正确舍入,同时认证实部与虚部;`tan`/`asin`/`acos`/`atan` 仅在极点/奇点极近处精度下降。 - **复数功能覆盖。** `CBig` 提供域运算和基本超越函数;复数双曲函数、`fma` 及其他若干功能推迟到 0.5.x(参见 v0.5 版本说明)。 - **没有球体/区间运算。** 与 MPC 的实验性 `mpcb_t`(复数球体)不同,`dashu` 不提供区间或球体类型。这是一个刻意的范围选择:球体运算在上游仍是实验性的,如果/当它稳定后,最好由一个**独立的 crate** 在 `dashu-float`/`dashu-cmplx` 之上提供,而非耦合到核心类型。 - **尚无 SIMD-FFT 乘法**(计划在 v1.0 中实现)。 diff --git a/guide/src/compliance.md b/guide/src/compliance.md index d3670698..0ee5b587 100644 --- a/guide/src/compliance.md +++ b/guide/src/compliance.md @@ -56,7 +56,7 @@ The reference here is IEEE Std 754™-2008 (ISO/IEC/IEEE 60559:2011). | IEEE 754 requirement | Compliance | Notes | |---------------------|-----------|-------| | Rounding modes: roundTiesToEven, roundTiesToAway, roundTowardPositive, roundTowardNegative, roundTowardZero | ✅ | All five modes implemented as `HalfEven`, `HalfAway`, `Up`, `Down`, `Zero`. | -| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the real transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` for a non-integer exponent) are guaranteed-correctly rounded via a Ziv loop; an integer-valued `powf` exponent delegates to `powi` (within 1 ulp), and `dashu-cmplx`'s complex transcendental *wrappers* route through the now-correct real primitives (within 1 ulp). The `Rounded` type distinguishes exact from inexact results. | +| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the real transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` for a non-integer exponent) are guaranteed-correctly rounded via a Ziv loop; an integer-valued `powf` exponent delegates to `powi` (within 1 ulp); and `dashu-cmplx`'s complex transcendentals (`exp`, `log`, `powf`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, `sqrt`) are correctly rounded via their own Ziv loop that certifies both parts (`tan`/`asin`/`acos`/`atan` lose accuracy only very near their poles/singularities). The `Rounded` type distinguishes exact from inexact results. | | Round-to-nearest preserves sign of zero | ✅ | `rounded_to_repr` preserves input sign when rounding collapses a non-zero to zero. | #### Section 5.6 — Sign bit operations @@ -152,7 +152,7 @@ the `Context` layer (and panics at the convenience layer). |---------------------|-----------|-------| | Invalid / indeterminate form → NaN | ❌ Deviates | `Err(FpError::{Indeterminate, InfiniteInput})` at `Context`; panics at the convenience layer. No NaN by design. | | Domain error (e.g. even root of a negative value, out-of-range inverse trig) | ❌ Deviates | `Err(FpError::OutOfDomain)` / panic, rather than a NaN result. | -| Each component rounded independently to the shared mode | ✅ | Near-correctly rounded per axis (a guaranteed-correct Ziv loop for the elementary real transcendentals has landed in `dashu-float`; the complex transcendentals still use a guard-digit recipe, within 1 ulp). | +| Each component rounded independently to the shared mode | ✅ | Correctly rounded per axis via a Ziv loop that certifies both parts, mirroring `dashu-float`'s real transcendentals (`tan`/`asin`/`acos`/`atan` lose accuracy only very near their poles/singularities). | ### Summary (dashu-cmplx) diff --git a/guide/src/faq.md b/guide/src/faq.md index 826ca297..fa66beee 100644 --- a/guide/src/faq.md +++ b/guide/src/faq.md @@ -29,7 +29,7 @@ Compared with other Rust crates: ## Known limitations - **No NaN.** Invalid operations panic at the convenience layer and return `Err(FpError)` at the context layer. Infinities are terminal values, not operands — see [Standards Compliance](./compliance.md). -- **Correctly-rounded transcendentals.** The real transcendentals — `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` (non-integer exponent) — are guaranteed-correctly rounded via a Ziv retry loop. An integer-valued `powf` exponent delegates to `powi` (binary exponentiation, within 1 ulp), and `dashu-cmplx`'s complex transcendental *wrappers* are still near-correct (within 1 ulp), routing through the now-correct real primitives. +- **Correctly-rounded transcendentals.** The real transcendentals — `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` (non-integer exponent) — are guaranteed-correctly rounded via a Ziv retry loop. An integer-valued `powf` exponent delegates to `powi` (binary exponentiation, within 1 ulp). `dashu-cmplx`'s complex transcendentals (`exp`, `log`, `powf`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, `sqrt`) are correctly rounded via their own Ziv loop that certifies both parts; `tan`/`asin`/`acos`/`atan` lose accuracy only very near their poles/singularities. - **Complex surface.** `CBig` ships field arithmetic and the elementary transcendentals; complex hyperbolics, `fma`, and several others are deferred to 0.5.x (see the v0.5 release notes). - **No ball/interval arithmetic.** Unlike MPC's experimental `mpcb_t` (complex balls), `dashu` does not provide interval or ball types. This is a deliberate scope choice: ball arithmetic is From dbe3bfe736edd5d2fbfb3cfb5ca94a0ad9305743 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Tue, 21 Jul 2026 16:13:18 +0800 Subject: [PATCH 14/21] Complex tan via the cancellation-free double-angle form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace tan's sin(z)/cos(z) (computed through the complex div, which catastrophically cancels in the real part for large |Im z| — sin and cos are both ~cosh(y)-scale, so sin·conj(cos) cancels from ~cosh²y down to O(1)) with the double-angle identity tan(x+iy) = (sin 2x + i sinh 2y) / (cos 2x + cosh 2y). The denominator cos 2x + cosh 2y is a sum of a bounded term and one >= 1, so it never cancels; the result is accurate for all finite |Im z|, and a plain result.ulp() * C radius is sound (the B^{1-pw} workaround and the large-|Im z| caveat are both gone). The only small-denominator points are the real-axis poles (y = 0, x = pi/2 + k pi), where the large value is genuine. tan now operates on the float parts (FloatCtxt) directly, like exp/log/ sqrt, rather than driving whole-CBig sin_cos + div. Broadened the tan_self_oracle to a moderate-large |Im z| strategy (up to ~40) and added a tan_large_imaginary_is_near_i test. Docs/changelog updated to drop tan from the limitation list. Co-Authored-By: Claude --- complex/CHANGELOG.md | 11 ++--- complex/src/math/trig.rs | 61 ++++++++++++++++++---------- complex/tests/transcendental_prop.rs | 20 +++++++-- guide-zh/src/compliance.md | 4 +- guide-zh/src/faq.md | 2 +- guide/src/compliance.md | 4 +- guide/src/faq.md | 2 +- 7 files changed, 68 insertions(+), 36 deletions(-) diff --git a/complex/CHANGELOG.md b/complex/CHANGELOG.md index 75e02aba..3f69e06f 100644 --- a/complex/CHANGELOG.md +++ b/complex/CHANGELOG.md @@ -13,11 +13,12 @@ guard digits while either part's error interval straddles a rounding boundary — matching `dashu-float`'s real transcendentals. Each transcendental reports a provable per-part error radius (`result.ulp() × C`, plus an amplification term where the composition magnifies error: `log`'s - `ln|z|` near `|z| = 1`, `tan`'s real part for large `|Im z|`, `powf`'s result magnitude). `abs` - delegates directly to `dashu-float`'s already-correctly-rounded `hypot`, dropping a double-rounding - re-round. The well-conditioned regime is guaranteed-correctly rounded; `tan`/`asin`/`acos`/`atan` - lose accuracy only very near their poles/singularities (a known limitation of the underlying - formulas, shared with the prior near-correct implementation). + `ln|z|` near `|z| = 1`, `powf`'s result magnitude). `tan` uses the cancellation-free double-angle + form `(sin 2x + i·sinh 2y)/(cos 2x + cosh 2y)` (the naive `sin z/cos z` cancels in the real part for + large `|Im z|`), so it is accurate for all finite `|Im z|`. `abs` delegates directly to + `dashu-float`'s already-correctly-rounded `hypot`, dropping a double-rounding re-round. The + well-conditioned regime is guaranteed-correctly rounded; `asin`/`acos`/`atan` lose accuracy only + very near their singularities (`z = ±1`/`±i`), a known limitation of the underlying formulas. ### Change - **(breaking, bound)** The complex transcendentals (`exp`, `ln`, `powf`, `sin`/`cos`/`tan`/ diff --git a/complex/src/math/trig.rs b/complex/src/math/trig.rs index 603118ce..397c8194 100644 --- a/complex/src/math/trig.rs +++ b/complex/src/math/trig.rs @@ -91,17 +91,16 @@ impl Context { self.sin_cos(z, cache).1 } - /// Complex tangent `sin z / cos z` (context layer), correctly rounded via a Ziv loop. Both - /// halves come from the (Ziv-backed) complex `sin_cos` at the working precision, then a complex - /// `div`. Even near the real-axis poles (`cos z ≈ 0`) the relative error stays bounded — `tan` - /// and its sensitivity to `cos` both scale as `1/cos` — so a small constant radius certifies - /// both parts (the large-but-finite near-pole value is held by dashu's wide exponent range). + /// Complex tangent (context layer), correctly rounded via a Ziv loop, using the cancellation-free + /// double-angle identity /// - /// For large `|Im z|`, `sin z` and `cos z` are both `~cosh(y)`-scale, and the division - /// `sin·conj(cos)/|cos|²` cancels in the real part down to `O(1)`; that cancellation error is - /// `~ulp(1)` at the working precision (independent of `|y|`), so the real-part radius carries an - /// extra absolute `B^{1-pw}` term to stay sound there (the loop then simply retries with more - /// guard until the cancellation is resolved). + /// `tan(x+iy) = (sin 2x + i·sinh 2y) / (cos 2x + cosh 2y)`. + /// + /// The denominator `cos 2x + cosh 2y` is a sum of a bounded term (`cos 2x ∈ [−1, 1]`) and a + /// term `≥ 1` (`cosh 2y`), so it never catastrophically cancels — unlike `sin z / cos z`, whose + /// `sin·conj(cos)` real part cancels from `~cosh²y` down to `O(1)` for large `|Im z|`. The result + /// is accurate for all finite `|Im z|`; the only small-denominator points are the real-axis poles + /// (`y = 0, x = π/2 + kπ`), where the large value is genuine, not an artifact. pub fn tan( &self, z: &CBig, @@ -110,17 +109,25 @@ impl Context { let p = self.precision(); let [re, im] = self.ziv(TRIG_GUARD, |guard| { let pw = p + guard; - let gctx = Context::new(pw); - let (sin_z, cos_z) = gctx.sin_cos(z, reborrow_cache(&mut cache)); - let sin_z = sin_z?.value(); - let cos_z = cos_z?.value(); - let tan_z = gctx.div(&sin_z, &cos_z)?.value(); - let (re, im) = tan_z.into_parts(); - // `B^{1-pw}` covers the real part's division-cancellation error for large `|Im z|`. - let cancel_scale = FBig::::from_parts(IBig::from(1), 1 - pw as isize); - let re_rad = re.ulp() * 10 + cancel_scale * 10; - let im_rad = im.ulp() * 10; - Ok([(re, re_rad), (im, im_rad)]) + let gctx = FloatCtxt::::new(pw); + // 2x, 2y (exact doublings — same significand, exponent +1). + let x2 = gctx.add(z.re(), z.re())?.value(); + let y2 = gctx.add(z.im(), z.im())?.value(); + let (sin2x, cos2x) = gctx.sin_cos(x2.repr(), reborrow_cache(&mut cache)); + let sin2x = sin2x?.value(); + let cos2x = cos2x?.value(); + let (sinh2y, cosh2y) = gctx.sinh_cosh(y2.repr(), reborrow_cache(&mut cache)); + let sinh2y = sinh2y?.value(); + let cosh2y = cosh2y?.value(); + // D = cos 2x + cosh 2y (a benign sum: a bounded term plus one ≥ 1). + let denom = gctx.add(cos2x.repr(), cosh2y.repr())?.value(); + let re = gctx.div(sin2x.repr(), denom.repr())?.value(); + let im = gctx.div(sinh2y.repr(), denom.repr())?.value(); + // re-root to the working precision (`sin_cos`/`sinh_cosh`/`div` may return exact + // constants for exact cases such as `tan(0) = 0`). + let re = re.with_precision(pw).value(); + let im = im.with_precision(pw).value(); + Ok([(re.clone(), re.ulp() * 8), (im.clone(), im.ulp() * 8)]) })?; Ok(combine_parts(re, im)) } @@ -315,6 +322,18 @@ mod tests { assert!(im.abs_cmp(&F::from_parts(1.into(), -12)).is_le()); } + #[test] + fn tan_large_imaginary_is_near_i() { + use dashu_base::{Abs, AbsOrd}; + // tan(x + i·100) ≈ i: real part → 0, imaginary → tanh(100) ≈ 1. The cancellation-free + // double-angle form computes this accurately; the naive `sin/cos` division would cancel the + // real part to noise for such a large `|Im z|` (the motivating case for the new formula). + let (re, im) = c(1, 100).tan().into_parts(); + let tol = F::from_parts(1.into(), -40); + assert!(re.abs().abs_cmp(&tol).is_le()); + assert!((im - F::from(1)).abs().abs_cmp(&tol).is_le()); + } + #[test] fn sin_i_is_i_sinh_one() { // sin(i) = i·sinh(1) = i·1.1752… ; purely imaginary. Use a *limited*-precision input — diff --git a/complex/tests/transcendental_prop.rs b/complex/tests/transcendental_prop.rs index 57d0490c..09865960 100644 --- a/complex/tests/transcendental_prop.rs +++ b/complex/tests/transcendental_prop.rs @@ -32,6 +32,19 @@ fn small_strategy() -> impl Strategy { }) } +/// Modest real part (away from the poles `π/2 + kπ`) with a moderate imaginary part up to ~40 — +/// large enough that the naive `sin z / cos z` division would catastrophically cancel in the real +/// part, small enough that `cosh(2·im)` stays cheap. For the cancellation-free `tan` oracle. +fn tan_strategy() -> impl Strategy { + ((1i64..(1i64 << 20), -4isize..4isize), 1i64..40i64).prop_map(|((re_sig, re_exp), im_num)| { + let re = F::from_parts(re_sig.into(), re_exp) + .with_precision(P) + .value(); + let im = F::from_parts(im_num.into(), 0).with_precision(P).value(); + CBig::from_parts(re, im) + }) +} + fn within_ulps(a: &F, b: &F, k: u32) -> bool { if a == b { return true; @@ -69,10 +82,9 @@ proptest! { } #[test] - fn tan_self_oracle(z in small_strategy()) { - // bounded |Im z|: for large |Im z| the division `sin/cos` cancels in the real part (a known - // limitation of the formula), so this oracle covers the well-conditioned regime where tan is - // correctly rounded via Ziv. 1 ULP. + fn tan_self_oracle(z in tan_strategy()) { + // tan uses the cancellation-free double-angle form, so it stays correct for moderate-large + // |Im z| (where the naive sin/cos division would cancel). 1 ULP. let lo = Context::new(P); let hi = Context::new(2 * P); let rp = lo.tan(&z, None).unwrap().value(); diff --git a/guide-zh/src/compliance.md b/guide-zh/src/compliance.md index b48f7ca1..db90ccfe 100644 --- a/guide-zh/src/compliance.md +++ b/guide-zh/src/compliance.md @@ -51,7 +51,7 @@ | IEEE 754 要求 | 合规性 | 说明 | |---------------------|-----------|-------| | 舍入模式:roundTiesToEven、roundTiesToAway、roundTowardPositive、roundTowardNegative、roundTowardZero | ✅ | 全部五种模式实现为 `HalfEven`、`HalfAway`、`Up`、`Down`、`Zero`。 | -| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及实超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`)通过 Ziv 循环保证正确舍入;整数指数的 `powf` 委托给 `powi`(在 1 ulp 以内);`dashu-cmplx` 的复数超越函数(`exp`、`log`、`powf`、`sin`/`cos`/`tan`/`sin_cos`、`asin`/`acos`/`atan`、`sqrt`)经由其自身的 Ziv 循环正确舍入,同时认证实部与虚部(`tan`/`asin`/`acos`/`atan` 仅在极点/奇点极近处精度下降)。`Rounded` 类型区分精确与非精确结果。 | +| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及实超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`)通过 Ziv 循环保证正确舍入;整数指数的 `powf` 委托给 `powi`(在 1 ulp 以内);`dashu-cmplx` 的复数超越函数(`exp`、`log`、`powf`、`sin`/`cos`/`tan`/`sin_cos`、`asin`/`acos`/`atan`、`sqrt`)经由其自身的 Ziv 循环正确舍入,同时认证实部与虚部(`asin`/`acos`/`atan` 仅在奇点(`z = ±1`/`±i`)极近处精度下降)。`Rounded` 类型区分精确与非精确结果。 | | 就近舍入保持零的符号 | ✅ | `rounded_to_repr` 在舍入将非零值折叠为零时保持输入符号。 | #### 第 5.6 节——符号位运算 @@ -143,7 +143,7 @@ |---------------------|-----------|-------| | 无效/不定形式 → NaN | ❌ 偏离 | 在 `Context` 层返回 `Err(FpError::{Indeterminate, InfiniteInput})`;在便捷层 panic。设计上无 NaN。 | | 定义域错误(如负数的偶数根、超出范围的逆三角函数) | ❌ 偏离 | 返回 `Err(FpError::OutOfDomain)` / panic,而非 NaN 结果。 | -| 每个分量独立按共享模式舍入 | ✅ | 每个轴经由同时认证两部分的 Ziv 循环正确舍入,与 `dashu-float` 的实超越函数一致(`tan`/`asin`/`acos`/`atan` 仅在极点/奇点极近处精度下降)。 | +| 每个分量独立按共享模式舍入 | ✅ | 每个轴经由同时认证两部分的 Ziv 循环正确舍入,与 `dashu-float` 的实超越函数一致(`asin`/`acos`/`atan` 仅在奇点(`z = ±1`/`±i`)极近处精度下降)。 | ### 小结(dashu-cmplx) diff --git a/guide-zh/src/faq.md b/guide-zh/src/faq.md index 889ca3d2..dee69f7a 100644 --- a/guide-zh/src/faq.md +++ b/guide-zh/src/faq.md @@ -26,7 +26,7 @@ ## 已知限制 - **没有 NaN。** 无效运算在便捷层会 panic,在上下文层返回 `Err(FpError)`。无穷是终态值,不是操作数——参见[标准合规性](./compliance.md)。 -- **正确舍入的超越函数。** 实超越函数——`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`——均通过 Ziv 重试循环保证正确舍入。整数指数的 `powf` 委托给 `powi`(二进制幂运算,1 ulp 以内)。`dashu-cmplx` 的复数超越函数(`exp`、`log`、`powf`、`sin`/`cos`/`tan`/`sin_cos`、`asin`/`acos`/`atan`、`sqrt`)经由其自身的 Ziv 循环正确舍入,同时认证实部与虚部;`tan`/`asin`/`acos`/`atan` 仅在极点/奇点极近处精度下降。 +- **正确舍入的超越函数。** 实超越函数——`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`——均通过 Ziv 重试循环保证正确舍入。整数指数的 `powf` 委托给 `powi`(二进制幂运算,1 ulp 以内)。`dashu-cmplx` 的复数超越函数(`exp`、`log`、`powf`、`sin`/`cos`/`tan`/`sin_cos`、`asin`/`acos`/`atan`、`sqrt`)经由其自身的 Ziv 循环正确舍入,同时认证实部与虚部;`tan` 采用免消去的双角公式(对所有有限 `|Im z|` 精确),`asin`/`acos`/`atan` 仅在奇点(`z = ±1`/`±i`)极近处精度下降。 - **复数功能覆盖。** `CBig` 提供域运算和基本超越函数;复数双曲函数、`fma` 及其他若干功能推迟到 0.5.x(参见 v0.5 版本说明)。 - **没有球体/区间运算。** 与 MPC 的实验性 `mpcb_t`(复数球体)不同,`dashu` 不提供区间或球体类型。这是一个刻意的范围选择:球体运算在上游仍是实验性的,如果/当它稳定后,最好由一个**独立的 crate** 在 `dashu-float`/`dashu-cmplx` 之上提供,而非耦合到核心类型。 - **尚无 SIMD-FFT 乘法**(计划在 v1.0 中实现)。 diff --git a/guide/src/compliance.md b/guide/src/compliance.md index 0ee5b587..b67985e7 100644 --- a/guide/src/compliance.md +++ b/guide/src/compliance.md @@ -56,7 +56,7 @@ The reference here is IEEE Std 754™-2008 (ISO/IEC/IEEE 60559:2011). | IEEE 754 requirement | Compliance | Notes | |---------------------|-----------|-------| | Rounding modes: roundTiesToEven, roundTiesToAway, roundTowardPositive, roundTowardNegative, roundTowardZero | ✅ | All five modes implemented as `HalfEven`, `HalfAway`, `Up`, `Down`, `Zero`. | -| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the real transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` for a non-integer exponent) are guaranteed-correctly rounded via a Ziv loop; an integer-valued `powf` exponent delegates to `powi` (within 1 ulp); and `dashu-cmplx`'s complex transcendentals (`exp`, `log`, `powf`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, `sqrt`) are correctly rounded via their own Ziv loop that certifies both parts (`tan`/`asin`/`acos`/`atan` lose accuracy only very near their poles/singularities). The `Rounded` type distinguishes exact from inexact results. | +| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the real transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` for a non-integer exponent) are guaranteed-correctly rounded via a Ziv loop; an integer-valued `powf` exponent delegates to `powi` (within 1 ulp); and `dashu-cmplx`'s complex transcendentals (`exp`, `log`, `powf`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, `sqrt`) are correctly rounded via their own Ziv loop that certifies both parts (`asin`/`acos`/`atan` lose accuracy only very near their singularities, `z = ±1`/`±i`). The `Rounded` type distinguishes exact from inexact results. | | Round-to-nearest preserves sign of zero | ✅ | `rounded_to_repr` preserves input sign when rounding collapses a non-zero to zero. | #### Section 5.6 — Sign bit operations @@ -152,7 +152,7 @@ the `Context` layer (and panics at the convenience layer). |---------------------|-----------|-------| | Invalid / indeterminate form → NaN | ❌ Deviates | `Err(FpError::{Indeterminate, InfiniteInput})` at `Context`; panics at the convenience layer. No NaN by design. | | Domain error (e.g. even root of a negative value, out-of-range inverse trig) | ❌ Deviates | `Err(FpError::OutOfDomain)` / panic, rather than a NaN result. | -| Each component rounded independently to the shared mode | ✅ | Correctly rounded per axis via a Ziv loop that certifies both parts, mirroring `dashu-float`'s real transcendentals (`tan`/`asin`/`acos`/`atan` lose accuracy only very near their poles/singularities). | +| Each component rounded independently to the shared mode | ✅ | Correctly rounded per axis via a Ziv loop that certifies both parts, mirroring `dashu-float`'s real transcendentals (`asin`/`acos`/`atan` lose accuracy only very near their singularities, `z = ±1`/`±i`). | ### Summary (dashu-cmplx) diff --git a/guide/src/faq.md b/guide/src/faq.md index fa66beee..49be4a0b 100644 --- a/guide/src/faq.md +++ b/guide/src/faq.md @@ -29,7 +29,7 @@ Compared with other Rust crates: ## Known limitations - **No NaN.** Invalid operations panic at the convenience layer and return `Err(FpError)` at the context layer. Infinities are terminal values, not operands — see [Standards Compliance](./compliance.md). -- **Correctly-rounded transcendentals.** The real transcendentals — `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` (non-integer exponent) — are guaranteed-correctly rounded via a Ziv retry loop. An integer-valued `powf` exponent delegates to `powi` (binary exponentiation, within 1 ulp). `dashu-cmplx`'s complex transcendentals (`exp`, `log`, `powf`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, `sqrt`) are correctly rounded via their own Ziv loop that certifies both parts; `tan`/`asin`/`acos`/`atan` lose accuracy only very near their poles/singularities. +- **Correctly-rounded transcendentals.** The real transcendentals — `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` (non-integer exponent) — are guaranteed-correctly rounded via a Ziv retry loop. An integer-valued `powf` exponent delegates to `powi` (binary exponentiation, within 1 ulp). `dashu-cmplx`'s complex transcendentals (`exp`, `log`, `powf`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, `sqrt`) are correctly rounded via their own Ziv loop that certifies both parts; `tan` uses the cancellation-free double-angle form (accurate for all finite `|Im z|`), and `asin`/`acos`/`atan` lose accuracy only very near their singularities (`z = ±1`/`±i`). - **Complex surface.** `CBig` ships field arithmetic and the elementary transcendentals; complex hyperbolics, `fma`, and several others are deferred to 0.5.x (see the v0.5 release notes). - **No ball/interval arithmetic.** Unlike MPC's experimental `mpcb_t` (complex balls), `dashu` does not provide interval or ball types. This is a deliberate scope choice: ball arithmetic is From 294ede1922265ab802313df70d9eea92b7e77a64 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Tue, 21 Jul 2026 16:40:59 +0800 Subject: [PATCH 15/21] Fix no_std build of the test-only Ziv retry counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LAST_ZIV_RETRIES thread_local! (used by the retry-count tests) needs std — thread_local! isn't in scope under --no-default-features, so both float/src/ziv.rs and complex/src/ziv.rs failed to compile in the no_std CI job (cargo test --no-default-features --features rand). Gate the counter, its loop uses, and the counter-reading tests behind feature = "std" (#[cfg(all(test, feature = "std"))]). The Ziv driver itself is now no_std-clean; the retry-count tests still run under std as before. Co-Authored-By: Claude --- complex/CHANGELOG.md | 3 +++ complex/src/ziv.rs | 8 ++++---- float/CHANGELOG.md | 5 +++++ float/src/ziv.rs | 12 ++++++------ 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/complex/CHANGELOG.md b/complex/CHANGELOG.md index 3f69e06f..0ae8d882 100644 --- a/complex/CHANGELOG.md +++ b/complex/CHANGELOG.md @@ -36,6 +36,9 @@ which is the exact `self.float()` (precision 0) at unlimited, so they are exact there. `div`/`inv` use the same path and panic at unlimited via `dashu-float`'s `div` (a quotient isn't exactly representable in general). `abs` asserts limited before delegating to the float `hypot`. +- **`no_std` build of the test-only Ziv retry counter.** The `LAST_ZIV_RETRIES` `thread_local!` + requires `std`, so the crate failed to compile under `--no-default-features`. It's now gated behind + `feature = "std"` (with its uses and the counter-reading tests), so the Ziv driver is `no_std`-clean. ## 0.5.0 (Initial release) diff --git a/complex/src/ziv.rs b/complex/src/ziv.rs index c0ef7201..f3ee217e 100644 --- a/complex/src/ziv.rs +++ b/complex/src/ziv.rs @@ -48,7 +48,7 @@ const MAX_ZIV_RETRIES: usize = 32; // A test-only retry counter (extra attempts beyond the first), mirroring `dashu-float`'s counter // so tests can assert the loop converges on the first attempt for typical inputs — i.e. `0` means // first-attempt success. -#[cfg(test)] +#[cfg(all(test, feature = "std"))] thread_local! { pub(crate) static LAST_ZIV_RETRIES: core::cell::Cell = const { core::cell::Cell::new(0) }; } @@ -76,7 +76,7 @@ impl Context { let p = self.precision(); let mut guard = initial_guard; let mut last = None; - #[cfg(test)] + #[cfg(all(test, feature = "std"))] LAST_ZIV_RETRIES.with(|c| c.set(0)); for _ in 0..MAX_ZIV_RETRIES { let parts = approx(guard)?; @@ -96,7 +96,7 @@ impl Context { // first attempt (with the heuristic guard) handles the common case (matches float). let step = core::cmp::max(guard, p / 2).max(1); guard += step; - #[cfg(test)] + #[cfg(all(test, feature = "std"))] LAST_ZIV_RETRIES.with(|c| c.set(c.get() + 1)); } @@ -146,7 +146,7 @@ impl Context { } } -#[cfg(test)] +#[cfg(all(test, feature = "std"))] mod tests { use super::*; use crate::cbig::CBig; diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index 8e5951bc..f9b49399 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -80,6 +80,11 @@ precision input, since a `p`-digit rational can't sit closer than ~`B⁻ᵖ` to the irrational pole) is handled by the Ziv-closure's `significand.is_zero()` retry guard, which forces a higher guard where cos is representable. +- **`no_std` build of the test-only Ziv retry counter.** The `LAST_ZIV_RETRIES` `thread_local!` + (used by the retry-count tests) requires `std`, so the crate failed to compile under + `--no-default-features` (the `thread_local!` macro isn't in scope). It's now gated behind + `feature = "std"` along with its uses and the counter-reading tests, so the Ziv driver itself is + `no_std`-clean; the retry-count tests run under `std` as before. ## 0.5.0 diff --git a/float/src/ziv.rs b/float/src/ziv.rs index b6aa3393..3aea4f63 100644 --- a/float/src/ziv.rs +++ b/float/src/ziv.rs @@ -38,7 +38,7 @@ const MAX_ZIV_RETRIES: usize = 32; // A test-only retry counter, so tests can assert the loop converges on the first attempt for // typical inputs (validating that the guard-digit heuristic wasn't over-tightened). Reads as // the number of *extra* attempts beyond the first, i.e. `0` means first-attempt success. -#[cfg(test)] +#[cfg(all(test, feature = "std"))] thread_local! { pub(crate) static LAST_ZIV_RETRIES: core::cell::Cell = const { core::cell::Cell::new(0) }; } @@ -68,7 +68,7 @@ impl Context { let mut guard = initial_guard; let mut last = None; - #[cfg(test)] + #[cfg(all(test, feature = "std"))] LAST_ZIV_RETRIES.with(|c| c.set(0)); for _ in 0..MAX_ZIV_RETRIES { let (a, e) = approx(guard); @@ -84,7 +84,7 @@ impl Context { // the first attempt (with the heuristic guard) handles the common case. let step = core::cmp::max(guard, self.precision / 2).max(1); guard += step; - #[cfg(test)] + #[cfg(all(test, feature = "std"))] LAST_ZIV_RETRIES.with(|c| c.set(c.get() + 1)); } @@ -112,7 +112,7 @@ impl Context { let mut guard = initial_guard; let mut last = None; - #[cfg(test)] + #[cfg(all(test, feature = "std"))] LAST_ZIV_RETRIES.with(|c| c.set(0)); for _ in 0..MAX_ZIV_RETRIES { let ((a1, e1), (a2, e2)) = approx(guard); @@ -128,7 +128,7 @@ impl Context { // Grow the guard aggressively so a near-tie resolves in a couple of retries. let step = core::cmp::max(guard, self.precision / 2).max(1); guard += step; - #[cfg(test)] + #[cfg(all(test, feature = "std"))] LAST_ZIV_RETRIES.with(|c| c.set(c.get() + 1)); } @@ -174,7 +174,7 @@ impl Context { } } -#[cfg(test)] +#[cfg(all(test, feature = "std"))] mod tests { use super::*; use crate::round::mode; From b50348eb859643ca20f8954b5e8f20cd5e5b2722 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Wed, 22 Jul 2026 00:17:33 +0800 Subject: [PATCH 16/21] powi: guaranteed-correct rounding via the Ziv loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `Context::powi` (and the `FBig::powi` convenience, the `CachedFBig` forwarder, and the `num_traits::Pow` impls) from `R: Round` to `R: ErrorBounds`, and wrap the binary-exponentiation squaring chain in a Ziv retry loop. The squaring kernel is factored into a shared `powi_chain` helper that also reports whether every step rounded `Exact` (the result is mathematically exact); `exp_compute` reuses it (with its own precision inflation) for the internal `Bⁿ` powering. Repeated squaring compounds the relative error (it roughly doubles per step), so after `bit_len(n)` squarings the error is bounded by about `2^nlen · ulp`, which the Ziv radius (`ulp_w << (nlen + 1)`) reflects. A negative exponent now computes `(1/base)^|n|` directly, so the sign-dependent overflow/underflow falls out naturally (the old reverse-rounding reciprocal trick is dropped). When the squaring chain is exact (the result is exactly representable, e.g. an integer power that fits), the true error is zero and the radius is reported as zero. This is required under the directed rounding modes — `FBig`'s default is `mode::Zero` — where an exactly-representable result lies on a one-sided rounding boundary that no nonzero radius can fit inside (the loop would retry forever). `powf`'s integer-valued-exponent delegation now reaches a correctly-rounded `powi`. Adds a `powi_rounding_exact_oracle` proptest. Co-Authored-By: Claude --- float/CHANGELOG.md | 16 +- float/src/exp.rs | 310 ++++++++++++++++++---------- float/src/fbig_cached_ops.rs | 2 +- float/src/third_party/num_traits.rs | 4 +- float/tests/exp_log_root_prop.rs | 11 + 5 files changed, 231 insertions(+), 112 deletions(-) diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index f9b49399..4ebcf1ff 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -36,9 +36,18 @@ radius is `result.ulp()·(|y·ln x|+1)·(B+8)` taken at the *working* precision: it shrinks as `B^{-guard}`, so the containment test converges (a radius computed at unlimited precision would be constant across retries and never settle for a value near a rounding boundary). An - integer-valued exponent now delegates to `powi` (binary exponentiation), which also admits a + integer-valued exponent delegates to `powi` (binary exponentiation), which also admits a negative base — its sign fixed by the exponent's parity — so `powf(-x, n)` is in domain for - integer `n`. That integer-exponent path stays within 1 ulp (near-correct), matching `powi`. + integer `n`. +- **Guaranteed-correct rounding for `powi` (integer exponent)** via the Ziv loop. Binary + exponentiation (repeated squaring) compounds the relative error — it roughly doubles per + squaring — so after `bit_len(n)` squarings the error is bounded by about `2^nlen · ulp`, which + the Ziv radius reflects (`ulp_w << (nlen + 1)`). A negative exponent computes `(1/base)^|n|` + directly, so the sign-dependent overflow/underflow falls out naturally. When the squaring chain + rounds `Exact` (the result is exactly representable, e.g. an integer power that fits), the true + error is zero and the radius is reported as zero — this is required under the *directed* rounding + modes (`Zero`/`Down`/`Up`/`Away`, the `FBig` default), where an exactly-representable result lies + on a one-sided rounding boundary that no nonzero radius can fit inside. ### Change - **(breaking, bound)** `Context::exp`/`exp_m1`/`ln`/`ln_1p` (and `powf`, the hyperbolic family, @@ -46,7 +55,8 @@ `R: Round` — the Ziv containment test needs the rounding preimage that `ErrorBounds` provides. All six built-in modes satisfy `ErrorBounds`; only custom non-`ErrorBounds` `Round` modes are affected (custom modes are already discouraged). `CachedFBig`/`CachedCBig` forwarders for these - methods carry the same bound. Arithmetic, roots, trigonometric, and `powi` remain `R: Round`. + methods carry the same bound. Arithmetic, roots, and trigonometric remain `R: Round`; `powi` + now also requires `R: ErrorBounds` (it is correctly rounded via the Ziv loop). - **Internal dedup** (no behavior change): the `⌈log_B(precision)⌉` base-guard formula shared by every transcendental Ziv loop is now `Context::base_guard_digits::()` (12 call sites in `hyper`/`exp`/ `log`), and the `ulp·(4·terms + 12)` series-truncation radius is now `series_radius(value, terms)` diff --git a/float/src/exp.rs b/float/src/exp.rs index e5fd5f63..9daaf7af 100644 --- a/float/src/exp.rs +++ b/float/src/exp.rs @@ -8,9 +8,11 @@ use crate::{ round::{ErrorBounds, Round}, }; use dashu_base::{Abs, AbsOrd, Approximation::*, BitTest, DivRemEuclid, EstimatedLog2, Sign}; -use dashu_int::IBig; +use dashu_int::{IBig, UBig}; -impl FBig { +// `powi` (integer power), `powf`/`exp`/`exp_m1` route through Ziv-backed Context methods, which +// require `R: ErrorBounds` for their correctness guarantee. +impl FBig { /// Raise the floating point number to an integer power. /// /// # Examples @@ -27,11 +29,7 @@ impl FBig { pub fn powi(&self, exp: IBig) -> FBig { self.context.unwrap_fp(self.context.powi(&self.repr, exp)) } -} -// `powf`/`exp`/`exp_m1` route through the Ziv-backed Context methods, which require `R: ErrorBounds` -// for their correctness guarantee. -impl FBig { /// Raise the floating point number to an floating point power. /// /// # Examples @@ -87,106 +85,48 @@ impl FBig { } } -// TODO: give the exact formulation of required guard bits - impl Context { - /// Raise the floating point number to an integer power under this context. + /// Left-to-right binary exponentiation of `start` to the power `n` (`n ≥ 2`) at this context's + /// precision — the shared squaring kernel. /// - /// # Examples - /// - /// ``` - /// # use dashu_base::ParseError; - /// # use dashu_float::DBig; - /// # use core::str::FromStr; - /// use dashu_base::Approximation::*; - /// use dashu_float::{Context, round::{mode::HalfAway, Rounding::*}}; + /// Each `sqr`/`mul` is correctly rounded, but their rounding flags are folded away (`.value()`) + /// and no containment test is applied, so the result is only *near*-correct: repeated squaring + /// compounds the relative error (it roughly doubles per step), so after `n.bit_len()` squarings + /// the error is on the order of `2^nlen · ulp`. The public [`powi`](Context::powi) retries this + /// kernel inside a Ziv loop to certify the rounding; `exp_compute` also uses it for its internal + /// `Bⁿ` powering (where the outer `exp` Ziv loop absorbs the error). /// - /// let context = Context::::new(2); - /// let a = DBig::from_str("-1.234")?; - /// assert_eq!(context.powi(&a.repr(), 10.into()), Ok(Inexact(DBig::from_str("8.2")?, AddOne))); - /// # Ok::<(), ParseError>(()) - /// ``` - /// - /// # Panics - /// - /// Panics if the precision is unlimited and the exponent is negative. In this case, the exact - /// result is likely to have infinite digits. - pub fn powi(&self, base: &Repr, exp: IBig) -> FpResult> { - if base.is_infinite() { - return Err(FpError::InfiniteInput); - } - - let (exp_sign, exp) = exp.into_parts(); - if exp_sign == Sign::Negative { - // if the exponent is negative, then negate the exponent - // note that do the inverse at last requires less guard bits - assert_limited_precision(self.precision); // TODO: we can allow this if the inverse is exact (only when significand is one?) - - let guard_bits = self.precision.bit_len() * 2; // heuristic - let rev_context = Context::::new(self.precision + guard_bits); - let pow = rev_context.unwrap_fp(rev_context.powi(base, exp.into())); - let inv = rev_context.unwrap_fp_repr(rev_context.repr_div(Repr::one(), pow.repr)); - let repr = self.repr_round(inv); - return Ok(repr.map(|v| FBig::new(v, *self))); - } - if exp.is_zero() { - return Ok(Exact(FBig::ONE)); - } else if exp.is_one() { - let repr = self.repr_round_ref(base); - return Ok(repr.map(|v| FBig::new(v, *self))); - } - - // Guard against exponent overflow for astronomically large results: the result - // magnitude has log2 ≈ exp·log2(base); if that exceeds the isize exponent range, - // return ±inf (|base| > 1) or 0 (|base| < 1) instead of overflowing mid-computation. - let base_log2 = base.log2_est() as f64; - let threshold = (isize::MAX as f64) * (B.log2_est() as f64); - let exp_f64 = i64::try_from(&exp).ok().map(|e| e as f64); - let overflows = match exp_f64 { - Some(e) => e * base_log2 > threshold, - None => base_log2 != 0.0, // exp doesn't fit i64: overflows unless |base| == 1 - }; - if overflows { - return if base_log2 > 0.0 { - Err(FpError::Overflow(if base.sign() == Sign::Negative { - Sign::Negative - } else { - Sign::Positive - })) - } else { - // |base| < 1 and exponent huge → underflow to signed zero - let underflow_sign = if base.sign() == Sign::Negative && exp.bit(0) { - Sign::Negative - } else { - Sign::Positive - }; - Err(FpError::Underflow(underflow_sign)) - }; - } - - let work_context = if self.is_limited() { - // increase working precision when the exponent is large - let guard_digits = exp.bit_len() + self.precision.bit_len(); // heuristic - Context::::new(self.precision + guard_digits) - } else { - Context::::new(0) - }; - - // binary exponentiation from left to right - let mut p = exp.bit_len() - 2; - let mut res = work_context.unwrap_fp(work_context.sqr(base)); + /// Returns the value together with an `exact` flag that is `true` only when **every** squaring + /// and multiplication rounded `Exact` (so the returned value is the mathematically exact + /// `startⁿ`). The Ziv caller uses this to report a zero radius for exact results — under + /// directed rounding modes an exactly-representable result sits on a one-sided rounding + /// boundary, which a nonzero radius can never certify. + pub(crate) fn powi_chain( + &self, + start: &Repr, + n: &UBig, + ) -> (FBig, bool) { + let nlen = n.bit_len(); + debug_assert!(nlen >= 2, "powi_chain requires n >= 2"); + let mut p = nlen - 2; + let first = self.sqr(start); + let mut exact = matches!(first, Ok(Exact(_))); + let mut res = self.unwrap_fp(first); loop { - if exp.bit(p) { - res = work_context.unwrap_fp(work_context.mul(res.repr(), base)); + if n.bit(p) { + let m = self.mul(res.repr(), start); + exact = exact && matches!(m, Ok(Exact(_))); + res = self.unwrap_fp(m); } if p == 0 { break; } p -= 1; - res = work_context.unwrap_fp(work_context.sqr(res.repr())); + let s = self.sqr(res.repr()); + exact = exact && matches!(s, Ok(Exact(_))); + res = self.unwrap_fp(s); } - - Ok(res.with_precision(self.precision)) + (res, exact) } /// Near-correct exp core: evaluate `exp(x)` (or `exp_m1(x)` when `minus_one`) at @@ -260,8 +200,15 @@ impl Context { // Powering amplifies the series' relative error by Bⁿ. With |v|/|sum| < e < 3 (both // near 1, since |r| < B⁻ⁿ), |v − true| ≤ 3·Bⁿ·(4K+8)·ulp(sum) + ulp(v). The B^s shift // is exact, so the bound shifts with the value. - let pow_ctx = Context::::new(work_precision); - let v = pow_ctx.unwrap_fp(pow_ctx.powi(sum.repr(), Repr::::BASE.pow(n).into())); + // + // The squaring chain compounds the relative error (it doubles per step), so it is run + // at an inflated precision and rounded back to `work_precision` — near-correct, which + // is what the `+ ulp(v)` term above accounts for. + let bn: UBig = Repr::::BASE.pow(n); + let chain_ctx = + Context::::new(work_precision + bn.bit_len() + work_precision.bit_len()); + let (v_pow, _) = chain_ctx.powi_chain(sum.repr(), &bn); + let v = v_pow.with_precision(work_precision).value(); let v_shifted = v.clone() << s; let e_v = (ulp_w() << n as isize) * (4 * terms + 8) * 3u32 + v.ulp().with_precision(0).value(); @@ -297,16 +244,166 @@ pub(crate) fn exp_overflows( >::try_from(s_probe).is_err() } -// `powf` (non-integer exponent), `exp`, and `exp_m1` are correctly rounded via the Ziv loop, so -// they require `R: ErrorBounds`. `powf` with an integer-valued exponent delegates to `powi` -// (`R: Round`, near-correct within 1 ulp). +// `powi` (integer power), `powf` (non-integer exponent), `exp`, and `exp_m1` are correctly rounded +// via the Ziv loop, so they require `R: ErrorBounds`. `powf` with an integer-valued exponent +// delegates to `powi`. impl Context { + /// Raise the floating point number to an integer power under this context, correctly rounded + /// via a Ziv retry loop. + /// + /// `base^n` is computed by left-to-right binary exponentiation (repeated squaring); a negative + /// exponent computes `(1/base)^|n|`, so the sign-dependent overflow/underflow falls out + /// naturally. Each squaring compounds the relative error (it roughly doubles per step), so + /// after `n.bit_len()` squarings the error is bounded by about `2^nlen · ulp` — the Ziv radius + /// reflects that, and the loop retries with more guard digits until the working-precision + /// interval unambiguously determines the target rounding. + /// + /// # Examples + /// + /// ``` + /// # use dashu_base::ParseError; + /// # use dashu_float::DBig; + /// # use core::str::FromStr; + /// use dashu_base::Approximation::*; + /// use dashu_float::{Context, round::{mode::HalfAway, Rounding::*}}; + /// + /// let context = Context::::new(2); + /// let a = DBig::from_str("-1.234")?; + /// assert_eq!(context.powi(&a.repr(), 10.into()), Ok(Inexact(DBig::from_str("8.2")?, AddOne))); + /// # Ok::<(), ParseError>(()) + /// ``` + /// + /// # Panics + /// + /// Panics if the precision is unlimited and the exponent is negative (the exact `1/base` is + /// not finite in general). + pub fn powi(&self, base: &Repr, exp: IBig) -> FpResult> { + if base.is_infinite() { + return Err(FpError::InfiniteInput); + } + let (exp_sign, n) = exp.into_parts(); + let negative = exp_sign == Sign::Negative; + if negative { + // a negative exponent needs 1/base, which is not finite at unlimited precision + assert_limited_precision(self.precision); + } + + if n.is_zero() { + return Ok(Exact(FBig::ONE)); + } + if n.is_one() { + if negative { + // base^(-1) = 1/base: a single correctly-rounded division + return self.div(&Repr::one(), base); + } + let repr = self.repr_round_ref(base); + return Ok(repr.map(|v| FBig::new(v, *self))); + } + + // Zero base (±0): a positive exponent gives ±0, a negative one ±inf; the sign follows |n|'s + // parity. Short-circuit before the magnitude pre-check (whose log2 estimate is meaningless + // for zero) and before the squaring chain (which can't start from zero). + let odd = n.bit(0); + if base.significand.is_zero() { + let neg_sign = base.sign() == Sign::Negative && odd; + if negative { + let sign = if neg_sign { + Sign::Negative + } else { + Sign::Positive + }; + return Ok(Exact(FBig::new(Repr::::infinity_with_sign(sign), *self))); + } + let repr = if neg_sign { + Repr::::neg_zero() + } else { + Repr::::zero() + }; + return Ok(Exact(FBig::new(repr, *self))); + } + + // Magnitude pre-check: the result's log2 is `signed_exp · log2|base|`; outside the finite + // exponent range it short-circuits to overflow/underflow instead of letting the squaring + // chain overflow mid-computation (the Ziv closure below can't return `Err`). + let base_log2 = base.log2_est() as f64; + let threshold = (isize::MAX as f64) * (B.log2_est() as f64); + let result_log2 = match i64::try_from(&n).ok() { + Some(e) => { + let signed = if negative { -(e as f64) } else { e as f64 }; + signed * base_log2 + } + None => { + // |n| doesn't fit i64: the magnitude is unbounded. |base| ≈ 1 (estimate 0) gives + // exactly ±1 regardless of the huge exponent; otherwise it over- or underflows. + if base_log2 == 0.0 { + let repr = if base.sign() == Sign::Negative && odd { + Repr::::neg_one() + } else { + Repr::::one() + }; + return Ok(Exact(FBig::new(repr, *self))); + } + let over = (!negative && base_log2 > 0.0) || (negative && base_log2 < 0.0); + let sign = if base.sign() == Sign::Negative && odd { + Sign::Negative + } else { + Sign::Positive + }; + return Err(if over { + FpError::Overflow(sign) + } else { + FpError::Underflow(sign) + }); + } + }; + if result_log2 > threshold || result_log2 < -threshold { + let sign = if base.sign() == Sign::Negative && odd { + Sign::Negative + } else { + Sign::Positive + }; + return Err(if result_log2 > threshold { + FpError::Overflow(sign) + } else { + FpError::Underflow(sign) + }); + } + + let nlen = n.bit_len(); + let initial_guard = nlen + self.base_guard_digits::() + 2; + Ok(self.ziv(initial_guard, |guard| { + let pw = self.precision + guard; + let work = Context::::new(pw); + // start from base (positive exponent, always exact) or its working-precision + // reciprocal (negative exponent, exact only when 1/base is exactly representable). + let (start, start_exact) = if negative { + let d = work.div(&Repr::one(), base); + let exact = matches!(d, Ok(Exact(_))); + (work.unwrap_fp(d).repr().clone(), exact) + } else { + (base.clone(), true) + }; + let (res, chain_exact) = work.powi_chain(&start, &n); + // When the whole computation is exact (start exact + no squaring rounded), `res` is the + // exact value and the true error is 0 — report a zero radius. This is required under + // directed rounding modes, where an exactly-representable result lies on a one-sided + // rounding boundary that no nonzero radius can fit inside (the Ziv loop would retry + // forever). Otherwise the squaring compounds the error ~`2^nlen · ulp_w`. + let radius = if pw == 0 || (start_exact && chain_exact) { + FBig::ZERO + } else { + res.ulp().with_precision(0).value() << (nlen as isize + 1) + }; + (res, radius) + })) + } + /// Raise the floating point number to an floating point power under this context. /// /// A non-integer exponent is correctly rounded via a Ziv loop. An integer-valued exponent /// delegates to [`powi`](Context::powi) (binary exponentiation), which also accepts a negative /// base — its sign is fixed by the exponent's parity — so `pow(-x, n)` is in domain here for - /// integer `n`. The integer-exponent path is within 1 ulp (near-correct), matching `powi`. + /// integer `n`. Both paths are correctly rounded. /// /// # Examples /// @@ -365,10 +462,11 @@ impl Context { return Ok(Exact(FBig::ONE)); } - // Integer-valued exponent: delegate to the integer-power kernel (binary exponentiation). - // This sidesteps the `exp(y·ln x)` amplification entirely, and lets a negative base through - // — `powi` fixes the sign from the exponent's parity. `powi` is near-correct (≤ 1 ulp). - // Gated on `is_int` (a cheap exponent check) so the non-integer common case skips `to_int`. + // Integer-valued exponent: delegate to the integer-power kernel (binary exponentiation), + // itself correctly rounded via its own Ziv loop. This sidesteps the `exp(y·ln x)` + // amplification entirely, and lets a negative base through — `powi` fixes the sign from + // the exponent's parity. Gated on `is_int` (a cheap exponent check) so the non-integer + // common case skips `to_int`. if exp.is_int() { return self.powi(base, exp.to_int().value()); } diff --git a/float/src/fbig_cached_ops.rs b/float/src/fbig_cached_ops.rs index 70c0e902..c95774c6 100644 --- a/float/src/fbig_cached_ops.rs +++ b/float/src/fbig_cached_ops.rs @@ -650,7 +650,6 @@ macro_rules! forward_to_fbig { impl CachedFBig { forward_to_fbig!(sqrt); forward_to_fbig!(inv); - forward_to_fbig!(powi(exp: dashu_int::IBig)); forward_to_fbig!(sqr); forward_to_fbig!(cubic); } @@ -658,6 +657,7 @@ impl CachedFBig { // Transcendentals that route through the Ziv-backed (or Ziv-dependent) Context methods require // `R: ErrorBounds` for their correctness guarantee. impl CachedFBig { + forward_to_fbig!(powi(exp: dashu_int::IBig)); forward_to_context!(ln); forward_to_context!(ln_1p); forward_to_context!(exp); diff --git a/float/src/third_party/num_traits.rs b/float/src/third_party/num_traits.rs index 96c5ffa6..04851ae2 100644 --- a/float/src/third_party/num_traits.rs +++ b/float/src/third_party/num_traits.rs @@ -103,14 +103,14 @@ impl num_traits::ToPrimitive for FBig { } } -impl num_traits::Pow for FBig { +impl num_traits::Pow for FBig { type Output = FBig; fn pow(self, rhs: IBig) -> Self { self.powi(rhs) } } -impl num_traits::Pow for &FBig { +impl num_traits::Pow for &FBig { type Output = FBig; fn pow(self, rhs: IBig) -> FBig { diff --git a/float/tests/exp_log_root_prop.rs b/float/tests/exp_log_root_prop.rs index fde193d9..f92f71f5 100644 --- a/float/tests/exp_log_root_prop.rs +++ b/float/tests/exp_log_root_prop.rs @@ -292,4 +292,15 @@ proptest! { let r_2p = b_2p.powf(&e_2p).with_precision(P).value(); prop_assert_eq!(r_p, r_2p); } + + /// Correct-rounding self-oracle for powi (positive and negative integer exponents, including + /// exact integer powers where the squaring chain introduces no rounding). + #[test] + fn powi_rounding_exact_oracle(b in signed_x(50_000), n in -12i32..=12) { + prop_assume!(n != 0); + let r_p = b.powi(IBig::from(n)); + let b_2p = b.clone().with_precision(2 * P).value(); + let r_2p = b_2p.powi(IBig::from(n)).with_precision(P).value(); + prop_assert_eq!(r_p, r_2p); + } } From 01ab1504de9f534dcd3f26d5edd05689fea566ff Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Wed, 22 Jul 2026 00:25:23 +0800 Subject: [PATCH 17/21] cmplx: guaranteed-correct rounding for powi via the Ziv loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `Context::powi` (and the `CBig::powi` convenience and the `CachedCBig` forwarder) from `R: Round` to `R: ErrorBounds`, and wrap the binary-exponentiation squaring chain in the complex Ziv driver (N=2, fallible closure). A negative exponent now computes `(1/z)^|n|` directly, so overflow/underflow propagates from the closure with `?` (no separate inversion step). Repeated squaring compounds the relative error (roughly doubling per step), so the per-part radius is `ulp_w << (nlen + 3)` (the `+3` margin covers complex `sqr`/`mul` being near-correct rather than 0.5-ulp correct). When the whole chain — the initial value and every squaring/ multiplication — rounds `Exact`, the result is the mathematically exact `zⁿ` and the radius is reported as zero. As with `dashu-float`'s `powi`, this is required under the directed rounding modes (`CBig`'s default is `mode::Zero`), where an exactly-representable result lies on a one-sided rounding boundary that no nonzero radius can fit inside. Adds a `powi_self_oracle` proptest (positive and negative exponents). Co-Authored-By: Claude --- complex/CHANGELOG.md | 9 ++- complex/src/cbig_cached_ops.rs | 2 +- complex/src/exp.rs | 86 ++++++++++++++++++++-------- complex/tests/transcendental_prop.rs | 11 ++++ 4 files changed, 79 insertions(+), 29 deletions(-) diff --git a/complex/CHANGELOG.md b/complex/CHANGELOG.md index 0ae8d882..9baf4c8b 100644 --- a/complex/CHANGELOG.md +++ b/complex/CHANGELOG.md @@ -21,10 +21,13 @@ very near their singularities (`z = ±1`/`±i`), a known limitation of the underlying formulas. ### Change -- **(breaking, bound)** The complex transcendentals (`exp`, `ln`, `powf`, `sin`/`cos`/`tan`/ +- **(breaking, bound)** The complex transcendentals (`exp`, `ln`, `powf`, `powi`, `sin`/`cos`/`tan`/ `sin_cos`, `asin`/`acos`/`atan`) and their `CachedCBig` forwarders now require `R: ErrorBounds`, - inherited from `dashu-float`'s Ziv-backed real `exp`/`ln`/`sinh_cosh` primitives. `powi` and the - field arithmetic (`add`/`sub`/`mul`/`div`/`sqr`/`inv`) remain `R: Round`. + inherited from `dashu-float`'s Ziv-backed real `exp`/`ln`/`sinh_cosh` primitives. `powi` is now + correctly rounded via its own Ziv loop (repeated squaring, with a per-part radius that scales with + the squaring-compounding error and drops to zero when the chain is exact — required under the + directed rounding modes, `CBig`'s default). The field arithmetic + (`add`/`sub`/`mul`/`div`/`sqr`/`inv`) remains `R: Round`. ### Fix - **Unlimited-precision handling**, centralized in the Ziv driver: it asserts a limited context up diff --git a/complex/src/cbig_cached_ops.rs b/complex/src/cbig_cached_ops.rs index 69775deb..397749e4 100644 --- a/complex/src/cbig_cached_ops.rs +++ b/complex/src/cbig_cached_ops.rs @@ -377,7 +377,6 @@ impl CachedCBig { delegate_to_cbig!(conj); delegate_to_cbig!(proj); delegate_to_cbig!(mul_i(negative: bool)); - delegate_to_cbig!(powi(exp: IBig)); /// The squared modulus `re² + im²` (a real [`FBig`]) (see [`CBig::norm`]). #[inline] @@ -390,6 +389,7 @@ impl CachedCBig { // require `R: ErrorBounds`. impl CachedCBig { // cache-threading transcendentals + delegate_to_cbig!(powi(exp: IBig)); forward_cached!(ln => log); forward_cached!(exp => exp); forward_cached!(sin => sin); diff --git a/complex/src/exp.rs b/complex/src/exp.rs index 6de0edf3..45ceb97c 100644 --- a/complex/src/exp.rs +++ b/complex/src/exp.rs @@ -11,7 +11,7 @@ use crate::cbig::CBig; use crate::repr::{combine_parts, exact, reborrow_cache, riemann, CfpResult, Context}; use dashu_base::Approximation::*; use dashu_base::{Abs, BitTest, Sign}; -use dashu_float::round::{ErrorBounds, Round}; +use dashu_float::round::ErrorBounds; use dashu_float::{ConstCache, Context as FloatCtxt, FBig, FpError}; use dashu_int::{IBig, Word}; @@ -22,37 +22,75 @@ const EXP_GUARD: usize = 14; /// cancellation-prone path, so a larger guard than the bare arithmetic ops. const POWF_GUARD: usize = 22; -impl Context { - /// Raise a complex number to an integer power under this context (context layer), via repeated - /// squaring (branch-cut-free, cheaper than `exp(n·log z)`). No cache. +impl Context { + /// Raise a complex number to an integer power under this context (context layer), correctly + /// rounded via a Ziv loop over the binary-exponentiation (repeated-squaring) chain. No cache. /// - /// `powi(z, 0) = 1`; a negative exponent computes `powi(z, |n|)` then inverts. + /// `powi(z, 0) = 1`; a negative exponent computes `(1/z)^|n|` directly, so the + /// sign-dependent overflow/underflow propagates from the closure with `?`. Repeated squaring + /// compounds the relative error (it roughly doubles per step), so after `bit_len(n)` squarings + /// the per-part error is bounded by about `2^nlen · ulp`, which the radius reflects; complex + /// `sqr`/`mul` are near-correct (a few ulp, not 0.5), so the bound carries an extra margin. pub fn powi(&self, z: &CBig, exp: IBig) -> CfpResult { let (sign, n) = exp.into_parts(); if n.is_zero() { return Ok(Exact(CBig::ONE)); } let negative = sign == Sign::Negative; - let bitlen = n.bit_len(); - // left-to-right binary exponentiation, starting from the leading set bit - let mut acc = z.clone(); - for i in (0..bitlen - 1).rev() { - acc = self.sqr(&acc)?.value(); - if n.bit(i) { - acc = self.mul(&acc, z)?.value(); - } - } - // The intermediate rounding flags are folded away (the value is near-correctly rounded); - // for a negative exponent the final `inv` carries its own flags. - if negative { - self.inv(&acc) - } else { - Ok(Exact(acc)) + if n.is_one() { + // |n| == 1: z (positive) or 1/z (negative), a single op. + return if negative { + self.inv(z) + } else { + Ok(Exact(z.clone())) + }; } + + let p = self.precision(); + let nlen = n.bit_len(); + // Initial guard scales with `nlen` (the squaring-compounding loss) plus a margin for the + // near-correct complex `sqr`/`mul`; sized so the first attempt certifies a non-tie result. + let initial_guard = nlen + 6; + let [re, im] = self.ziv(initial_guard, |guard| { + let pw = p + guard; + let gctx = Context::new(pw); + // start from z (positive exponent, always exact) or its working-precision reciprocal + // (negative exponent, exact only when 1/z is exactly representable). + let (start, mut exact) = if negative { + let inv = gctx.inv(z)?; + let ex = matches!(inv, Exact(_)); + (inv.value(), ex) + } else { + (z.clone(), true) + }; + // left-to-right binary exponentiation, tracking whether every step rounded Exact. + let mut acc = start.clone(); + for i in (0..nlen - 1).rev() { + let s = gctx.sqr(&acc)?; + exact = exact && matches!(s, Exact(_)); + acc = s.value(); + if n.bit(i) { + let m = gctx.mul(&acc, &start)?; + exact = exact && matches!(m, Exact(_)); + acc = m.value(); + } + } + let (re, im) = acc.into_parts(); + // re-root to the working precision (parts may be exact constants). + let re = re.with_precision(pw).value(); + let im = im.with_precision(pw).value(); + let shift = (nlen + 3) as isize; + // When the whole chain is exact the result is the mathematically exact zⁿ: report a zero + // radius. This is required under the directed rounding modes (the `CBig` default), where + // an exactly-representable result lies on a one-sided rounding boundary that no nonzero + // radius can fit inside. (See `dashu-float`'s `powi` for the same reasoning.) + let re_r = if exact { FBig::ZERO } else { re.ulp() << shift }; + let im_r = if exact { FBig::ZERO } else { im.ulp() << shift }; + Ok([(re.clone(), re_r), (im.clone(), im_r)]) + })?; + Ok(combine_parts(re, im)) } -} -impl Context { /// Complex exponential under this context (context layer). Computes `e^x·(cos y + i·sin y)` /// from `dashu-float`'s (correctly-rounded) `exp` and `sin_cos`, wrapped in a Ziv loop that /// certifies both parts; the cache is threaded into both (the convenience layer passes `None`). @@ -138,7 +176,7 @@ impl Context { } } -impl CBig { +impl CBig { /// Integer power (convenience layer). /// /// # Panics @@ -148,9 +186,7 @@ impl CBig { pub fn powi(&self, exp: IBig) -> Self { self.context().unwrap_cfp(self.context().powi(self, exp)) } -} -impl CBig { /// Complex exponential `e^z` (convenience layer). /// /// # Panics diff --git a/complex/tests/transcendental_prop.rs b/complex/tests/transcendental_prop.rs index 09865960..bf48fda2 100644 --- a/complex/tests/transcendental_prop.rs +++ b/complex/tests/transcendental_prop.rs @@ -194,4 +194,15 @@ proptest! { let r2 = reround_hi(hi.powf(&base, &w, None).unwrap().value()); prop_assert!(within_ulps_cbig(&rp, &r2, 1)); } + + #[test] + fn powi_self_oracle(z in small_strategy(), n in -8i32..=8) { + prop_assume!(n != 0); + let lo = Context::new(P); + let hi = Context::new(2 * P); + let rp = lo.powi(&z, n.into()).unwrap().value(); + let r2 = reround_hi(hi.powi(&z, n.into()).unwrap().value()); + // powi is correctly rounded via Ziv (positive and negative integer exponents), 1 ULP. + prop_assert!(within_ulps_cbig(&rp, &r2, 1)); + } } From ab6e526cb8ad0e5d89e4e9f2f22a93224762541d Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Wed, 22 Jul 2026 00:29:26 +0800 Subject: [PATCH 18/21] =?UTF-8?q?cmplx:=20factor=201-z=C2=B2=20in=20asin/a?= =?UTF-8?q?cos=20for=20accuracy=20at=20the=20z=3D=C2=B11=20singularities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute the `1-z²` under `asin`/`acos`'s inner `sqrt` in the factored form `(1-z)(1+z)` instead of the direct `1 - sqr(z)`. Near `z = ±1` the direct form subtracts a `z²` that is dominated by the `sqr` rounding error from 1 (catastrophic cancellation), so `sqrt(1-z²)` — and thus `asin`/`acos` — lost all accuracy there. The factored form is Sterbenz-exact at `z = ±1`: `1-z`'s significand difference is computed exactly, so the existing `ulp·20` Ziv radius stays sound right up to the singularities (the analysis: with the factored form the per-part error near `z=±1` is `~ulp·√|1-z|`, which vanishes at the singularity). `atan` was already sound near its `z = ±i` singularities (its `1 ± iz` is an exact `mul_i` rotation plus an exact-significand add/sub — no cancellation to factor out), so it is unchanged. Adds a `near_one_strategy` and an `asin_acos_near_one_self_oracle` proptest that straddles `z = 1` (where the old form would fail). Co-Authored-By: Claude --- complex/CHANGELOG.md | 7 +++++-- complex/src/math/trig.rs | 27 +++++++++++++++++++-------- complex/tests/transcendental_prop.rs | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/complex/CHANGELOG.md b/complex/CHANGELOG.md index 9baf4c8b..41701479 100644 --- a/complex/CHANGELOG.md +++ b/complex/CHANGELOG.md @@ -17,8 +17,11 @@ form `(sin 2x + i·sinh 2y)/(cos 2x + cosh 2y)` (the naive `sin z/cos z` cancels in the real part for large `|Im z|`), so it is accurate for all finite `|Im z|`. `abs` delegates directly to `dashu-float`'s already-correctly-rounded `hypot`, dropping a double-rounding re-round. The - well-conditioned regime is guaranteed-correctly rounded; `asin`/`acos`/`atan` lose accuracy only - very near their singularities (`z = ±1`/`±i`), a known limitation of the underlying formulas. + well-conditioned regime is guaranteed-correctly rounded; `asin`/`acos` compute the inner `1-z²` in + the factored form `(1-z)(1+z)` (Sterbenz-exact near the singularities `z = ±1`, where the direct + `1 - z²` would cancel against the `sqr` rounding error), so they stay accurate right up to `z = ±1`. + (`atan` was already sound near its singularities `z = ±i`: its `1 ± iz` is built from an exact + rotation and an exact-significand add/sub, so there is no cancellation to factor out.) ### Change - **(breaking, bound)** The complex transcendentals (`exp`, `ln`, `powf`, `powi`, `sin`/`cos`/`tan`/ diff --git a/complex/src/math/trig.rs b/complex/src/math/trig.rs index 397c8194..b7d7b983 100644 --- a/complex/src/math/trig.rs +++ b/complex/src/math/trig.rs @@ -135,9 +135,12 @@ impl Context { /// Inverse sine `asin z = -i·log(iz + sqrt(1-z²))` (context layer, Kahan form), correctly /// rounded via a Ziv loop. The argument of the inner `log` always has positive real part, so the /// branch cut comes entirely from the `sqrt`; an infinite input maps to - /// [`FpError::Indeterminate`]. The composition (square/subtract/sqrt/add/log) is wrapped in a - /// Ziv loop with a generous constant radius — the retries absorb the cancellation near `z = ±1` - /// (where `1-z² → 0` and the `sqrt` amplifies) in the well-conditioned regime. + /// [`FpError::Indeterminate`]. The `1-z²` under the `sqrt` is computed in the factored form + /// `(1-z)(1+z)`, which is Sterbenz-exact near `z = ±1` (where the direct `1-z²` would + /// catastrophically cancel against the `sqr` rounding error), so the well-conditioned regime + /// extends right up to the singularities. A generous constant radius covers the + /// square/subtract/sqrt/add/log composition; the Ziv retries absorb the `sqrt` amplification as + /// `1-z² → 0`. pub fn asin( &self, z: &CBig, @@ -151,8 +154,13 @@ impl Context { let pw = p + guard; let gctx = Context::new(pw); let one = CBig::ONE; - let z2 = gctx.sqr(z)?.value(); - let one_m_z2 = gctx.sub(&one, &z2)?.value(); + // Factor `1-z² = (1-z)(1+z)`. Near `z = ±1` the direct `1 - z²` subtracts a value + // dominated by the `sqr` rounding error from 1 (catastrophic cancellation); the factored + // form is Sterbenz-exact there (`1-z` is computed exactly, since the subtraction's + // significand difference is exact), so the radius stays sound right up to the singularity. + let one_m_z = gctx.sub(&one, z)?.value(); + let one_p_z = gctx.add(&one, z)?.value(); + let one_m_z2 = gctx.mul(&one_m_z, &one_p_z)?.value(); let sqrt_term = gctx.sqrt(&one_m_z2)?.value(); let iz = z.mul_i(false); // exact rotation let w = gctx.add(&iz, &sqrt_term)?.value(); @@ -168,7 +176,8 @@ impl Context { } /// Inverse cosine `acos z = -i·log(z + i·sqrt(1-z²))` (context layer, Kahan form), correctly - /// rounded via a Ziv loop. Same composition and singularity structure as `asin`. + /// rounded via a Ziv loop. Same composition and singularity structure as `asin` (including the + /// factored `1-z² = (1-z)(1+z)` near `z = ±1`). pub fn acos( &self, z: &CBig, @@ -182,8 +191,10 @@ impl Context { let pw = p + guard; let gctx = Context::new(pw); let one = CBig::ONE; - let z2 = gctx.sqr(z)?.value(); - let one_m_z2 = gctx.sub(&one, &z2)?.value(); + // Factored `1-z² = (1-z)(1+z)` — Sterbenz-exact near `z = ±1` (see `asin`). + let one_m_z = gctx.sub(&one, z)?.value(); + let one_p_z = gctx.add(&one, z)?.value(); + let one_m_z2 = gctx.mul(&one_m_z, &one_p_z)?.value(); let sqrt_term = gctx.sqrt(&one_m_z2)?.value(); let i_sqrt = sqrt_term.mul_i(false); // i·sqrt(1-z²) let w = gctx.add(z, &i_sqrt)?.value(); diff --git a/complex/tests/transcendental_prop.rs b/complex/tests/transcendental_prop.rs index bf48fda2..7b8bd61b 100644 --- a/complex/tests/transcendental_prop.rs +++ b/complex/tests/transcendental_prop.rs @@ -45,6 +45,17 @@ fn tan_strategy() -> impl Strategy { }) } +/// Real part in roughly `(0.9, 1.1)` (straddling the `asin`/`acos` singularity `z = 1`) with a tiny +/// imaginary part — exercises the factored `1-z² = (1-z)(1+z)` form right at `z = ±1`, where the +/// direct `1 - z²` would catastrophically cancel against the `sqr` rounding error. +fn near_one_strategy() -> impl Strategy { + (90i64..110, -2i64..2).prop_map(|(re_num, im_num)| { + let re = F::from_parts(re_num.into(), -2).with_precision(P).value(); + let im = F::from_parts(im_num.into(), -8).with_precision(P).value(); + CBig::from_parts(re, im) + }) +} + fn within_ulps(a: &F, b: &F, k: u32) -> bool { if a == b { return true; @@ -205,4 +216,18 @@ proptest! { // powi is correctly rounded via Ziv (positive and negative integer exponents), 1 ULP. prop_assert!(within_ulps_cbig(&rp, &r2, 1)); } + + #[test] + fn asin_acos_near_one_self_oracle(z in near_one_strategy()) { + // z straddles the singularity at 1 (and the symmetric point -1 by conjugation), where the + // factored `1-z²=(1-z)(1+z)` keeps `asin`/`acos` accurate; the self-oracle checks 1 ULP. + let lo = Context::new(P); + let hi = Context::new(2 * P); + let rp = lo.asin(&z, None).unwrap().value(); + let r2 = reround_hi(hi.asin(&z, None).unwrap().value()); + prop_assert!(within_ulps_cbig(&rp, &r2, 1)); + let rp = lo.acos(&z, None).unwrap().value(); + let r2 = reround_hi(hi.acos(&z, None).unwrap().value()); + prop_assert!(within_ulps_cbig(&rp, &r2, 1)); + } } From bf54c676351f32284ce244bab7ae75db2e275ad3 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Wed, 22 Jul 2026 00:35:55 +0800 Subject: [PATCH 19/21] cmplx: exact-accumulating Sum for CBig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite `Sum for CBig` (and `Sum<&CBig>`) to exact-accumulate, matching `FBig: Sum`. Complex addition is componentwise, so the real and imaginary parts are split (via `CBig::into_parts`) and summed independently through `FBig`'s exact-accumulating `Sum` — each axis accumulates its `Repr`s losslessly and rounds once at the `Context::max` target. The result is the correctly-rounded complex sum. The previous componentwise fold rounded at every step, so a sum like `1e20 + 1 - 1e20` collapsed to `0` at low precision (the `+1` was lost when added to `1e20`); exact accumulation preserves it (yields `1`). `Product` stays a fold, matching `FBig: Product`. `CachedCBig` inherits the fix (its `Sum` delegates to `CBig`'s). Adds `sum_exact_cancellation` and `sum_exact_accumulates_wide_magnitudes` tests. Co-Authored-By: Claude --- complex/CHANGELOG.md | 5 ++++ complex/src/iter.rs | 54 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/complex/CHANGELOG.md b/complex/CHANGELOG.md index 41701479..60769c08 100644 --- a/complex/CHANGELOG.md +++ b/complex/CHANGELOG.md @@ -33,6 +33,11 @@ (`add`/`sub`/`mul`/`div`/`sqr`/`inv`) remains `R: Round`. ### Fix +- **`Sum` for `CBig` is now correctly rounded.** It exact-accumulates the real and imaginary + parts independently via `FBig`'s exact-accumulating `Sum` (lossless `Repr` accumulation, a single + final rounding per axis at the `Context::max` target), instead of the previous componentwise fold + whose per-step rounding could lose low-order terms (e.g. `1e20 + 1 - 1e20` folded to `0` at low + precision; it now correctly yields `1`). `Product` stays a fold, matching `FBig: Product`. - **Unlimited-precision handling**, centralized in the Ziv driver: it asserts a limited context up front, so every transcendental panics on precision 0 as documented (the exact special-value shortcuts — `exp(0)=1`, `log(0)=-∞`, `powf(z,0)=1`, `sqrt(±0)`, `sin/cos(0)`, etc. — still bypass diff --git a/complex/src/iter.rs b/complex/src/iter.rs index acd26150..b0376ab4 100644 --- a/complex/src/iter.rs +++ b/complex/src/iter.rs @@ -1,24 +1,38 @@ //! Implementation of core::iter traits for [`CBig`]. //! -//! `Sum`/`Product` fold with the binary `+`/`*` operators (componentwise perfectly-rounded real -//! ops on each part). The impls are concrete (`Sum`/`Sum<&CBig>`, `Product`/`Product<&CBig>`) -//! rather than generic over `Add`/`Mul`, matching the narrowed iter surface used for `FBig`. A -//! correctly-rounded (exact-accumulating) `Sum` for `CBig` is a possible future refinement. +//! `Sum` is exact-accumulating: since complex addition is componentwise, the real and imaginary +//! parts are summed independently via `FBig`'s exact-accumulating `Sum` (each axis accumulates its +//! `Repr`s losslessly and rounds once), so the result is the correctly-rounded complex sum. `Product` +//! folds with the binary `*` operator (componentwise near-correct). The impls are concrete +//! (`Sum`/`Sum<&CBig>`, `Product`/`Product<&CBig>`) rather than generic over `Add`/`Mul`, matching +//! the narrowed iter surface used for `FBig`. use crate::cbig::CBig; +use alloc::vec::Vec; use core::iter::{Product, Sum}; use dashu_float::round::Round; +use dashu_float::FBig; use dashu_int::Word; +/// Exact-accumulating complex sum: split into real/imaginary [`FBig`] streams and sum each via +/// `FBig`'s `Sum` (which accumulates `Repr`s exactly and rounds once at the target context). An +/// empty stream sums to `CBig::ZERO` (both axes are `FBig::ZERO`). +fn precise_sum( + parts: impl Iterator, FBig)>, +) -> CBig { + let (re_parts, im_parts): (Vec>, Vec>) = parts.unzip(); + CBig::from_parts(re_parts.into_iter().sum(), im_parts.into_iter().sum()) +} + impl Sum for CBig { fn sum>(iter: I) -> Self { - iter.fold(CBig::ZERO, |acc, x| acc + x) + precise_sum(iter.map(CBig::into_parts)) } } impl<'a, R: Round, const B: Word> Sum<&'a CBig> for CBig { fn sum>(iter: I) -> Self { - iter.fold(CBig::ZERO, |acc, x| acc + x) + precise_sum(iter.map(|c| c.clone().into_parts())) } } @@ -39,6 +53,7 @@ mod tests { use crate::cbig::CBig; use dashu_float::round::mode::HalfAway; use dashu_float::FBig; + use dashu_int::IBig; type C = CBig; type F = FBig; @@ -77,4 +92,31 @@ mod tests { let p: C = core::iter::empty::().product(); assert_eq!(p, C::ONE); } + + #[test] + fn sum_exact_cancellation() { + // z + (-z) = +0 on both axes (canonical +0, not -0, under HalfAway). + let z = C::from_parts(F::from(3), F::from(4)); + let s: C = [z, -C::from_parts(F::from(3), F::from(4))] + .into_iter() + .sum(); + assert_eq!(s, C::ZERO); + } + + #[test] + fn sum_exact_accumulates_wide_magnitudes() { + // At precision 4, (1e20 + 1 - 1e20) folds to 0 (the +1 is lost when added to 1e20), but + // exact accumulation preserves it: the sum is 1 on each axis. This distinguishes the + // exact-accumulating `Sum` from a naive fold. + let mk = |sig: i32, exp: isize| { + F::from_parts(IBig::from(sig), exp) + .with_precision(4) + .value() + }; + let big = C::from_parts(mk(1, 20), mk(1, 20)); + let one = C::from_parts(mk(1, 0), mk(1, 0)); + let neg_big = C::from_parts(mk(-1, 20), mk(-1, 20)); + let s: C = [big, one, neg_big].into_iter().sum(); + assert_eq!(s, C::from_parts(mk(1, 0), mk(1, 0))); + } } From 327e3e5f806c1ccb7dd983e9fca9fa88a3147dc0 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Wed, 22 Jul 2026 00:41:00 +0800 Subject: [PATCH 20/21] =?UTF-8?q?docs:=20mark=20powi=20correctly-rounded?= =?UTF-8?q?=20and=20asin/acos=20fixed=20at=20z=3D=C2=B11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the compliance tables and FAQ (English + Simplified-Chinese mirrors) and V1-ROADMAP.md to reflect the now-completed Ziv work: - `powi` (real and complex) is correctly rounded via its own Ziv loop, no longer "within 1 ulp" / "delegated near-correct". - `asin`/`acos` stay accurate right up to the singularities `z = ±1` via the factored `1-z²=(1-z)(1+z)`; drop the old "lose accuracy near z=±1/±i" caveat. - `CBig: Sum` is exact-accumulating (the roadmap's "not yet correctly-rounded" note is resolved). No heading text changed, so the mdBook CJK auto-anchor IDs are unchanged. Co-Authored-By: Claude --- V1-ROADMAP.md | 23 +++++++++++++---------- guide-zh/src/compliance.md | 4 ++-- guide-zh/src/faq.md | 2 +- guide/src/compliance.md | 4 ++-- guide/src/faq.md | 2 +- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/V1-ROADMAP.md b/V1-ROADMAP.md index 33f094d2..692f0414 100644 --- a/V1-ROADMAP.md +++ b/V1-ROADMAP.md @@ -33,19 +33,22 @@ are longer-term goals. File:line references are anchors from the v0.5.0 tree and ### Correctness -- **Guaranteed-correct rounding (Ziv retry loop).** ✅ *Delivered for all real transcendentals.* - `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric family (`sin`, `cos`, `sin_cos`, - `tan`, `asin`, `acos`, `atan`, `atan2`), the hyperbolic family (`sinh`, `cosh`, `sinh_cosh`, - `tanh`, `asinh`, `acosh`, `atanh`), `hypot`, and `powf` (non-integer exponent) are +- **Guaranteed-correct rounding (Ziv retry loop).** ✅ *Delivered for all real and complex + transcendentals.* `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric family (`sin`, `cos`, + `sin_cos`, `tan`, `asin`, `acos`, `atan`, `atan2`), the hyperbolic family (`sinh`, `cosh`, + `sinh_cosh`, `tanh`, `asinh`, `acosh`, `atanh`), `hypot`, `powi`, and `powf` are guaranteed-correctly rounded via a Ziv retry loop (`Context::ziv`/`ziv_pair`, driven by the `ErrorBounds` preimage). Series transcendentals (trig, atan) use near-correct `_compute` cores the wrapper certifies; composition transcendentals treat the Ziv-correct primitives as black boxes. `powf`'s `exp(y·ln x)` amplification is handled by a work-precision radius (`result.ulp()·(|y·ln x|+1)·(B+8)`) that shrinks as `B^{-guard}`, so the containment test - converges; an integer-valued `powf` exponent delegates to `powi` (≤ 1 ulp). Remaining: making - `powi` itself Ziv-correctly-rounded (currently near-correct, which the `powf` integer-exponent - path inherits), and `dashu-cmplx`'s complex transcendental *wrappers* (which route through these - now-correct real primitives but aren't themselves Ziv-wrapped). + converges. `powi` (repeated squaring) carries a radius that scales with the squaring-compounding + error (`2^bit_len(n)·ulp`) and drops to zero when the chain is exact — required under the directed + rounding modes (`FBig`/`CBig` default to `mode::Zero`), where an exactly-representable result sits + on a one-sided rounding boundary. `dashu-cmplx`'s complex transcendentals + (`exp`/`log`/`powf`/`powi`/`sin`/`cos`/`tan`/`sin_cos`/`asin`/`acos`/`atan`/`sqrt`) are + correctly rounded via their own complex Ziv driver (certifies both parts); `asin`/`acos` use the + factored `1-z²=(1-z)(1+z)` to stay accurate right up to `z = ±1`. - **Signed-zero preservation in `CBig` zero short-circuits.** `sin_cos` and `sqr` take a fast path on exactly-zero input that returns `+0` components, so several Annex-G / IEEE signed-zero cases are not preserved (all numerically equal to `+0`, hence deferred): @@ -66,8 +69,8 @@ Consolidated from the original `CBig` design. All additive. complex-valued functions themselves are deferred.) - **More transcendentals** — complex `fma` (fused multiply-add — hard to round correctly), `rootofunity`, complex `agm`, and `exp2`/`exp10`/`log2`/`log10`. -- **Vector ops** — `dot`/mean helpers and a correctly-rounded (exact-accumulating) `Sum` for - `CBig`. (Fold-based `Sum`/`Product` for `CBig` already exist; `Sum` is not yet correctly-rounded.) +- **Vector ops** — `dot`/mean helpers. (`Sum` for `CBig` is already exact-accumulating, matching + `FBig: Sum`; `Product` for `CBig` remains a fold, matching `FBig: Product`.) - **Independent re/im rounding** — a `CRound` trait giving MPC `mpc_rnd_t` parity (0.5 uses a single `R` for both parts). - **Third-party integration** — `CBig` `serde`/`rkyv`/`zeroize`, and diff --git a/guide-zh/src/compliance.md b/guide-zh/src/compliance.md index db90ccfe..7e3fecfd 100644 --- a/guide-zh/src/compliance.md +++ b/guide-zh/src/compliance.md @@ -51,7 +51,7 @@ | IEEE 754 要求 | 合规性 | 说明 | |---------------------|-----------|-------| | 舍入模式:roundTiesToEven、roundTiesToAway、roundTowardPositive、roundTowardNegative、roundTowardZero | ✅ | 全部五种模式实现为 `HalfEven`、`HalfAway`、`Up`、`Down`、`Zero`。 | -| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及实超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`)通过 Ziv 循环保证正确舍入;整数指数的 `powf` 委托给 `powi`(在 1 ulp 以内);`dashu-cmplx` 的复数超越函数(`exp`、`log`、`powf`、`sin`/`cos`/`tan`/`sin_cos`、`asin`/`acos`/`atan`、`sqrt`)经由其自身的 Ziv 循环正确舍入,同时认证实部与虚部(`asin`/`acos`/`atan` 仅在奇点(`z = ±1`/`±i`)极近处精度下降)。`Rounded` 类型区分精确与非精确结果。 | +| 正确舍入到 1 ulp 以内 | ✅ | 所有运算保证 $|error| < 1\text{ ulp}$。算术、根号以及实超越函数(`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`、`powi`、`powf`)通过 Ziv 循环保证正确舍入;`dashu-cmplx` 的复数超越函数(`exp`、`log`、`powf`、`powi`、`sin`/`cos`/`tan`/`sin_cos`、`asin`/`acos`/`atan`、`sqrt`)经由其自身的 Ziv 循环正确舍入,同时认证实部与虚部(`asin`/`acos` 采用因式分解的 $1-z^2=(1-z)(1+z)$,在奇点 $z = \pm 1$ 处亦保持精度)。`Rounded` 类型区分精确与非精确结果。 | | 就近舍入保持零的符号 | ✅ | `rounded_to_repr` 在舍入将非零值折叠为零时保持输入符号。 | #### 第 5.6 节——符号位运算 @@ -143,7 +143,7 @@ |---------------------|-----------|-------| | 无效/不定形式 → NaN | ❌ 偏离 | 在 `Context` 层返回 `Err(FpError::{Indeterminate, InfiniteInput})`;在便捷层 panic。设计上无 NaN。 | | 定义域错误(如负数的偶数根、超出范围的逆三角函数) | ❌ 偏离 | 返回 `Err(FpError::OutOfDomain)` / panic,而非 NaN 结果。 | -| 每个分量独立按共享模式舍入 | ✅ | 每个轴经由同时认证两部分的 Ziv 循环正确舍入,与 `dashu-float` 的实超越函数一致(`asin`/`acos`/`atan` 仅在奇点(`z = ±1`/`±i`)极近处精度下降)。 | +| 每个分量独立按共享模式舍入 | ✅ | 每个轴经由同时认证两部分的 Ziv 循环正确舍入,与 `dashu-float` 的实超越函数一致(`asin`/`acos` 采用因式分解的 $1-z^2=(1-z)(1+z)$,在奇点 $z = \pm 1$ 处亦保持精度)。 | ### 小结(dashu-cmplx) diff --git a/guide-zh/src/faq.md b/guide-zh/src/faq.md index dee69f7a..91e34745 100644 --- a/guide-zh/src/faq.md +++ b/guide-zh/src/faq.md @@ -26,7 +26,7 @@ ## 已知限制 - **没有 NaN。** 无效运算在便捷层会 panic,在上下文层返回 `Err(FpError)`。无穷是终态值,不是操作数——参见[标准合规性](./compliance.md)。 -- **正确舍入的超越函数。** 实超越函数——`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`,以及非整数指数的 `powf`——均通过 Ziv 重试循环保证正确舍入。整数指数的 `powf` 委托给 `powi`(二进制幂运算,1 ulp 以内)。`dashu-cmplx` 的复数超越函数(`exp`、`log`、`powf`、`sin`/`cos`/`tan`/`sin_cos`、`asin`/`acos`/`atan`、`sqrt`)经由其自身的 Ziv 循环正确舍入,同时认证实部与虚部;`tan` 采用免消去的双角公式(对所有有限 `|Im z|` 精确),`asin`/`acos`/`atan` 仅在奇点(`z = ±1`/`±i`)极近处精度下降。 +- **正确舍入的超越函数。** 实超越函数——`exp`、`exp_m1`、`ln`、`ln_1p`、三角与双曲函数族、`hypot`、`powi`、`powf`——均通过 Ziv 重试循环保证正确舍入。`dashu-cmplx` 的复数超越函数(`exp`、`log`、`powf`、`powi`、`sin`/`cos`/`tan`/`sin_cos`、`asin`/`acos`/`atan`、`sqrt`)经由其自身的 Ziv 循环正确舍入,同时认证实部与虚部;`tan` 采用免消去的双角公式(对所有有限 `|Im z|` 精确),`asin`/`acos` 采用因式分解的 $1-z^2=(1-z)(1+z)$(在 $z = \pm 1$ 处亦精确)。 - **复数功能覆盖。** `CBig` 提供域运算和基本超越函数;复数双曲函数、`fma` 及其他若干功能推迟到 0.5.x(参见 v0.5 版本说明)。 - **没有球体/区间运算。** 与 MPC 的实验性 `mpcb_t`(复数球体)不同,`dashu` 不提供区间或球体类型。这是一个刻意的范围选择:球体运算在上游仍是实验性的,如果/当它稳定后,最好由一个**独立的 crate** 在 `dashu-float`/`dashu-cmplx` 之上提供,而非耦合到核心类型。 - **尚无 SIMD-FFT 乘法**(计划在 v1.0 中实现)。 diff --git a/guide/src/compliance.md b/guide/src/compliance.md index b67985e7..3ca72e48 100644 --- a/guide/src/compliance.md +++ b/guide/src/compliance.md @@ -56,7 +56,7 @@ The reference here is IEEE Std 754™-2008 (ISO/IEC/IEEE 60559:2011). | IEEE 754 requirement | Compliance | Notes | |---------------------|-----------|-------| | Rounding modes: roundTiesToEven, roundTiesToAway, roundTowardPositive, roundTowardNegative, roundTowardZero | ✅ | All five modes implemented as `HalfEven`, `HalfAway`, `Up`, `Down`, `Zero`. | -| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the real transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` for a non-integer exponent) are guaranteed-correctly rounded via a Ziv loop; an integer-valued `powf` exponent delegates to `powi` (within 1 ulp); and `dashu-cmplx`'s complex transcendentals (`exp`, `log`, `powf`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, `sqrt`) are correctly rounded via their own Ziv loop that certifies both parts (`asin`/`acos`/`atan` lose accuracy only very near their singularities, `z = ±1`/`±i`). The `Rounded` type distinguishes exact from inexact results. | +| Correct rounding to within 1 ulp | ✅ | All operations guarantee $|error| < 1\text{ ulp}$. Arithmetic, roots, and the real transcendentals (`exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, `powi`, and `powf`) are guaranteed-correctly rounded via a Ziv loop; and `dashu-cmplx`'s complex transcendentals (`exp`, `log`, `powf`, `powi`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, `sqrt`) are correctly rounded via their own Ziv loop that certifies both parts (`asin`/`acos` use the factored $1-z^2=(1-z)(1+z)$ so they stay accurate right up to the singularities $z = \pm 1$). The `Rounded` type distinguishes exact from inexact results. | | Round-to-nearest preserves sign of zero | ✅ | `rounded_to_repr` preserves input sign when rounding collapses a non-zero to zero. | #### Section 5.6 — Sign bit operations @@ -152,7 +152,7 @@ the `Context` layer (and panics at the convenience layer). |---------------------|-----------|-------| | Invalid / indeterminate form → NaN | ❌ Deviates | `Err(FpError::{Indeterminate, InfiniteInput})` at `Context`; panics at the convenience layer. No NaN by design. | | Domain error (e.g. even root of a negative value, out-of-range inverse trig) | ❌ Deviates | `Err(FpError::OutOfDomain)` / panic, rather than a NaN result. | -| Each component rounded independently to the shared mode | ✅ | Correctly rounded per axis via a Ziv loop that certifies both parts, mirroring `dashu-float`'s real transcendentals (`asin`/`acos`/`atan` lose accuracy only very near their singularities, `z = ±1`/`±i`). | +| Each component rounded independently to the shared mode | ✅ | Correctly rounded per axis via a Ziv loop that certifies both parts, mirroring `dashu-float`'s real transcendentals (`asin`/`acos` use the factored $1-z^2=(1-z)(1+z)$ so they stay accurate right up to the singularities $z = \pm 1$). | ### Summary (dashu-cmplx) diff --git a/guide/src/faq.md b/guide/src/faq.md index 49be4a0b..c3feaf11 100644 --- a/guide/src/faq.md +++ b/guide/src/faq.md @@ -29,7 +29,7 @@ Compared with other Rust crates: ## Known limitations - **No NaN.** Invalid operations panic at the convenience layer and return `Err(FpError)` at the context layer. Infinities are terminal values, not operands — see [Standards Compliance](./compliance.md). -- **Correctly-rounded transcendentals.** The real transcendentals — `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, and `powf` (non-integer exponent) — are guaranteed-correctly rounded via a Ziv retry loop. An integer-valued `powf` exponent delegates to `powi` (binary exponentiation, within 1 ulp). `dashu-cmplx`'s complex transcendentals (`exp`, `log`, `powf`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, `sqrt`) are correctly rounded via their own Ziv loop that certifies both parts; `tan` uses the cancellation-free double-angle form (accurate for all finite `|Im z|`), and `asin`/`acos`/`atan` lose accuracy only very near their singularities (`z = ±1`/`±i`). +- **Correctly-rounded transcendentals.** The real transcendentals — `exp`, `exp_m1`, `ln`, `ln_1p`, the trigonometric and hyperbolic families, `hypot`, `powi`, and `powf` — are guaranteed-correctly rounded via a Ziv retry loop. `dashu-cmplx`'s complex transcendentals (`exp`, `log`, `powf`, `powi`, `sin`/`cos`/`tan`/`sin_cos`, `asin`/`acos`/`atan`, `sqrt`) are correctly rounded via their own Ziv loop that certifies both parts; `tan` uses the cancellation-free double-angle form (accurate for all finite `|Im z|`), and `asin`/`acos` use the factored $1-z^2=(1-z)(1+z)$ (accurate right up to $z = \pm 1$). - **Complex surface.** `CBig` ships field arithmetic and the elementary transcendentals; complex hyperbolics, `fma`, and several others are deferred to 0.5.x (see the v0.5 release notes). - **No ball/interval arithmetic.** Unlike MPC's experimental `mpcb_t` (complex balls), `dashu` does not provide interval or ball types. This is a deliberate scope choice: ball arithmetic is From 4ff74e2b75b948ce74b48a7e3c6b50f85097c098 Mon Sep 17 00:00:00 2001 From: Jacob Zhong Date: Sat, 25 Jul 2026 00:36:48 +0800 Subject: [PATCH 21/21] log2: guaranteed-correct rounding via the Ziv loop Add FBig::log2 / Context::log2 / CachedFBig::log2. log2(x) = ln(x)/ln(2), with each ln_compute reporting a provable error radius; the two radii are carried through the division as an outward-rounded interval [lo, hi] that the Ziv driver certifies. Rounding the operands separately toward the mode and dividing once is only near-correct (enlarging a positive denominator shrinks the quotient), so the directed result was previously unbounded. An exact power of two short-circuits to the integer log2(x). This is required under directed rounding (Down/Up/Zero): the Ziv containment test cannot certify an exactly-representable result sitting on a one-sided rounding boundary, so without the shortcut log2(2^-159) under Up returned -159 + 1 ulp instead of the exact -159. Co-Authored-By: Claude --- float/CHANGELOG.md | 9 ++ float/src/fbig_cached.rs | 1 + float/src/fbig_cached_ops.rs | 1 + float/src/log.rs | 277 ++++++++++++++++++++++++++++++++++- 4 files changed, 286 insertions(+), 2 deletions(-) diff --git a/float/CHANGELOG.md b/float/CHANGELOG.md index 1f512d84..b57da1a5 100644 --- a/float/CHANGELOG.md +++ b/float/CHANGELOG.md @@ -48,6 +48,15 @@ error is zero and the radius is reported as zero — this is required under the *directed* rounding modes (`Zero`/`Down`/`Up`/`Away`, the `FBig` default), where an exactly-representable result lies on a one-sided rounding boundary that no nonzero radius can fit inside. +- **`FBig::log2` / `Context::log2` / `CachedFBig::log2`**: base-2 logarithm, correctly rounded via + the Ziv loop. `log2(x) = ln(x)/ln(2)`, and rounding the two operands separately before the single + division is only near-correct — under a directed mode, enlarging the positive denominator shrinks + the quotient, so the directed result is unbounded — so each `ln_compute` reports its provable + radius and the two radii are carried through the division as an outward-rounded interval `[lo, hi]` + the Ziv driver certifies. An exact power of two short-circuits to the integer `log2(x)`; this is + required under directed modes, where the exactly-representable integer sits on a one-sided rounding + boundary no positive radius can fit inside (so `log2(2^-159)` under `Up` is the exact `-159`, not + `-159 + 1 ulp`). ### Change - **(breaking, bound)** `Context::exp`/`exp_m1`/`ln`/`ln_1p` (and `powf`, the hyperbolic family, diff --git a/float/src/fbig_cached.rs b/float/src/fbig_cached.rs index 1ea2a645..87bebc33 100644 --- a/float/src/fbig_cached.rs +++ b/float/src/fbig_cached.rs @@ -539,6 +539,7 @@ mod tests { assert_eq!(x.clone().cos().into_fbig(), y.clone().cos()); assert_eq!(x.clone().exp_m1().into_fbig(), y.clone().exp_m1()); assert_eq!(x.clone().ln_1p().into_fbig(), y.clone().ln_1p()); + assert_eq!(x.clone().log2().into_fbig(), y.clone().log2()); assert_eq!(x.powf(&x.clone()).into_fbig(), y.clone().powf(&y)); } diff --git a/float/src/fbig_cached_ops.rs b/float/src/fbig_cached_ops.rs index c95774c6..34aab1d4 100644 --- a/float/src/fbig_cached_ops.rs +++ b/float/src/fbig_cached_ops.rs @@ -660,6 +660,7 @@ impl CachedFBig { forward_to_fbig!(powi(exp: dashu_int::IBig)); forward_to_context!(ln); forward_to_context!(ln_1p); + forward_to_context!(log2); forward_to_context!(exp); forward_to_context!(exp_m1); diff --git a/float/src/log.rs b/float/src/log.rs index 77d923b6..3a5d2339 100644 --- a/float/src/log.rs +++ b/float/src/log.rs @@ -2,7 +2,7 @@ use dashu_base::{ utils::{next_down, next_up}, AbsOrd, Approximation::*, - EstimatedLog2, Sign, + EstimatedLog2, PowerOfTwo, Sign, UnsignedAbs, }; use dashu_int::IBig; @@ -12,7 +12,7 @@ use crate::{ math::cache::{reborrow_cache, ConstCache}, math::trig::series_radius, repr::{Context, Repr, Word}, - round::{ErrorBounds, Round, Rounded}, + round::{mode, ErrorBounds, Round, Rounded}, }; use core::cmp::Ordering; @@ -97,6 +97,26 @@ impl FBig { pub fn ln_1p(&self) -> Self { self.context.unwrap_fp(self.context.ln_1p(&self.repr, None)) } + + /// Calculate the base-2 logarithm (`log2(x)`) on the float number. + /// + /// Correctly rounded to the context's precision under any rounding mode. For an exact power + /// of two the result is the exact integer `log2(x)`. + /// + /// # Examples + /// + /// ``` + /// # use core::str::FromStr; + /// # use dashu_base::ParseError; + /// # use dashu_float::DBig; + /// let a = DBig::from_str("8")?; + /// assert_eq!(a.log2(), DBig::from_str("3")?); + /// # Ok::<(), ParseError>(()) + /// ``` + #[inline] + pub fn log2(&self) -> Self { + self.context.unwrap_fp(self.context.log2(&self.repr, None)) + } } // `ln2`/`ln10`/`iacoth`/`ln_base`/`ln_compute` are the near-correct logarithm primitives: they @@ -402,6 +422,137 @@ impl Context { self.ln_compute::(x, self.precision + guard, one_plus, reborrow_cache(&mut cache)) }) } + + /// Calculate the base-2 logarithm (`log2(x)`) on the float number under this context. + /// + /// Correctly rounded to the context's precision under any rounding mode; for an exact power + /// of two the result is the exact integer `log2(x)`. + /// + /// # Domain + /// + /// `log2(±0) = −∞` and a negative (non-zero) input is out of domain; an infinite input is an + /// error (a finite context cannot produce the infinite `log2(+∞) = +∞` exactly). + /// + /// # Examples + /// + /// ``` + /// # use core::str::FromStr; + /// # use dashu_base::ParseError; + /// # use dashu_float::DBig; + /// use dashu_base::Approximation::*; + /// use dashu_float::{Context, round::{mode::HalfAway, Rounding::*}}; + /// + /// let context = Context::::new(4); + /// let a = DBig::from_str("10")?; + /// assert_eq!(context.log2(&a.repr(), None), Ok(Inexact(DBig::from_str("3.322")?, AddOne))); + /// # Ok::<(), ParseError>(()) + /// ``` + #[inline] + pub fn log2( + &self, + x: &Repr, + cache: Option<&mut ConstCache>, + ) -> FpResult> { + if x.is_infinite() { + return Err(FpError::InfiniteInput); + } + if x.significand.is_zero() { + // log2(±0) = -inf (a value, not an error) + return Ok(Exact(FBig::new(Repr::neg_infinity(), *self))); + } + if x.sign() == Sign::Negative { + return Err(FpError::OutOfDomain); + } + Ok(self.log2_internal(x, cache)) + } + + fn log2_internal( + &self, + x: &Repr, + mut cache: Option<&mut ConstCache>, + ) -> Rounded> { + assert_finite(x); + + // Exact shortcuts first — they also cover unlimited precision, which the Ziv loop below + // rejects via its limited-precision assertion. + if x.is_one() { + return Exact(FBig::ZERO); // log2(1) = +0 + } + + // Exact power-of-two shortcut: if x = 2^k for an integer k, log2(x) = k. This is *required* + // for directed rounding — the Ziv loop below cannot certify an exactly-representable + // result whose true value sits on a rounding boundary (its shrinking error interval + // always straddles the boundary), so without this shortcut log2(2^-159) under `Up` would + // exhaust the retry cap and return k + 1 ulp instead of the exact k. + // + // log2(x) = log2(significand) + exponent·log2(B). With significand = 2^m this is an exact + // integer whenever log2(B) is integral (B a power of two), or — for a non-power-of-two + // base — when the exponent is zero. + let mag = (&x.significand).unsigned_abs(); + if mag.is_power_of_two() && (x.exponent == 0 || B.is_power_of_two()) { + let m = mag.trailing_zeros().unwrap(); // = log2(significand) + let log2_b = B.trailing_zeros() as isize; + let k = IBig::from(m) + IBig::from(x.exponent) * IBig::from(log2_b); + return self.convert_int::(k); + } + + assert_limited_precision(self.precision); + + // log2(x) = ln(x)/ln(2), correctly rounded via the Ziv loop. Rounding ln(x) and ln(2) + // separately and dividing once is only *near*-correct: under directed rounding, rounding + // both operands toward the mode does not bound the quotient (enlarging a positive + // denominator shrinks it). Instead each `ln_compute` reports a provable error radius, and + // the two radii are carried through the division as an outward-rounded interval [lo, hi] + // that is guaranteed to contain the true log2(x); the driver certifies once that interval + // lies inside a single rounding bin. + let initial_guard = self.base_guard_digits::() + 4; + self.ziv(initial_guard, |guard| { + let work_precision = self.precision + guard; + let (lx, ex) = + self.ln_compute::(x, work_precision, false, reborrow_cache(&mut cache)); + // ln(2) via the same near-correct primitive so it carries a provable radius too. + let two = Repr::new(IBig::from(2), 0); + let (l2, e2) = + self.ln_compute::(&two, work_precision, false, reborrow_cache(&mut cache)); + + // True ln(x) ∈ [lx−ex, lx+ex] and true ln(2) ∈ [l2−e2, l2+e2] ⊆ (0, ∞). With a + // positive denominator the quotient ln(x)/ln(2) is minimized by the low numerator + // over the high denominator and maximized by the converse. Directing each endpoint's + // rounding outward (lo down, hi up) keeps [lo, hi] a true containing interval. + let down = Context::::new(work_precision); + let up = Context::::new(work_precision); + let nx_lo = down.sub(&lx.repr, &ex.repr).unwrap().value(); + let nx_hi = up.add(&lx.repr, &ex.repr).unwrap().value(); + let d_lo = down.sub(&l2.repr, &e2.repr).unwrap().value(); + let d_hi = up.add(&l2.repr, &e2.repr).unwrap().value(); + debug_assert!( + d_lo.repr.sign() == Sign::Positive, + "ln(2) lower bound must stay positive (guard digits keep e2 ≪ ln 2 ≈ 0.693)" + ); + let lo = down.div(&nx_lo.repr, &d_hi.repr).unwrap().value(); + let hi = up.div(&nx_hi.repr, &d_lo.repr).unwrap().value(); + + // Working-precision estimate; the driver re-rounds it to the target precision, so the + // mode used here is immaterial to correctness. + let value = Context::::new(work_precision) + .div(&lx.repr, &l2.repr) + .unwrap() + .value(); + + // Radius: a provable bound on |value − true|. The true value lies in [lo, hi], and + // `value` is within one working ulp of lx/l2 ∈ [lo, hi], so |value − true| ≤ + // (hi − lo) + ulp_w. Computed at unlimited precision so the bound arithmetic is exact + // (no rounding that could under-report it), yet scaled by the working-precision span + // and ulp so it shrinks as the guard grows and the loop converges. `lo`/`hi` were + // rounded under Down/Up; their *values* are mode-independent, so rebuild them in the + // target mode R via their reprs to keep the arithmetic single-mode. + let unlim = Context::::new(0); + let span = FBig::new(hi.repr.clone(), unlim) - FBig::new(lo.repr.clone(), unlim); + let ulp_w = value.ulp().with_precision(0).value(); + let radius = span + ulp_w; + (value, radius) + }) + } } #[cfg(test)] @@ -472,4 +623,126 @@ mod tests { .unwrap() ); } + + #[test] + fn test_log2_domain() { + let ctx = Context::::new(53); + // log2(±0) = -inf (a value, not an error) + let r = ctx.log2::<2>(&Repr::<2>::zero(), None).unwrap().value(); + assert!(r.repr.is_infinite()); + assert_eq!(r.repr.sign(), Sign::Negative); + // log2(negative) is out of domain + assert!(matches!( + ctx.log2::<2>(&Repr::new((-1).into(), 0), None), + Err(FpError::OutOfDomain) + )); + // an infinite input is rejected + assert!(matches!(ctx.log2::<2>(&Repr::infinity(), None), Err(FpError::InfiniteInput))); + } + + #[test] + fn test_log2_exact_power_of_two() { + // log2(2^k) = k exactly under every rounding mode. Regression for the directed-rounding + // defect: rounding ln(x) and ln(2) each toward the mode and dividing once does not bound + // the quotient, so previously log2(2^-159) under `Up` returned -159 + 1 ulp. + let p = 53; + for k in [0isize, 1, -1, 5, 159, -159, 1000, -1000] { + let x = Repr::<2>::new(IBig::from(1), k); // 2^k + let r_down = Context::::new(p) + .log2::<2>(&x, None) + .unwrap() + .value(); + let r_up = Context::::new(p) + .log2::<2>(&x, None) + .unwrap() + .value(); + let r_zero = Context::::new(p) + .log2::<2>(&x, None) + .unwrap() + .value(); + let r_he = Context::::new(p) + .log2::<2>(&x, None) + .unwrap() + .value(); + // Every directed mode produces the identical value — no mode-dependent ulp. + assert_eq!(r_down.repr, r_he.repr, "Down != HalfEven for log2(2^{k})"); + assert_eq!(r_up.repr, r_he.repr, "Up != HalfEven for log2(2^{k})"); + assert_eq!(r_zero.repr, r_he.repr, "Zero != HalfEven for log2(2^{k})"); + // And that value is exactly k. + assert_eq!(r_he.to_int().value(), IBig::from(k), "value for log2(2^{k})"); + } + } + + #[test] + fn test_log2_exact_power_of_two_decimal_base() { + // In a non-power-of-two base the shortcut still fires when the exponent is zero: a + // significand that is itself a power of two makes x = 2^m exactly. + let p = 53; + for (sig, want) in [(8i32, 3isize), (1024, 10), (2, 1), (32, 5)] { + let x = Repr::<10>::new(IBig::from(sig), 0); + let r_down = Context::::new(p) + .log2::<10>(&x, None) + .unwrap() + .value(); + let r_up = Context::::new(p) + .log2::<10>(&x, None) + .unwrap() + .value(); + let r_he = Context::::new(p) + .log2::<10>(&x, None) + .unwrap() + .value(); + assert_eq!(r_down.repr, r_he.repr, "Down != HalfEven for log2({sig}) base 10"); + assert_eq!(r_up.repr, r_he.repr, "Up != HalfEven for log2({sig}) base 10"); + assert_eq!(r_he.to_int().value(), IBig::from(want), "value for log2({sig}) base 10"); + } + } + + /// For a non-power-of-two significand `sig` (so `log2` is irrational and never lands on a + /// rounding boundary), each directed result must equal a high-precision oracle rounded to the + /// target precision under the same mode — the definition of correct rounding. + fn check_log2_directed_matches_oracle(sig: u32, p: usize) { + let oracle_ctx = Context::::new(p + 40); + let x = Repr::::new(IBig::from(sig), 0); + let oracle = oracle_ctx.log2::(&x, None).unwrap().value(); + + let want_down = Context::::new(p) + .repr_round_ref(&oracle.repr) + .value(); + let want_up = Context::::new(p) + .repr_round_ref(&oracle.repr) + .value(); + let want_he = Context::::new(p) + .repr_round_ref(&oracle.repr) + .value(); + + let got_down = Context::::new(p) + .log2::(&x, None) + .unwrap() + .value(); + let got_up = Context::::new(p) + .log2::(&x, None) + .unwrap() + .value(); + let got_he = Context::::new(p) + .log2::(&x, None) + .unwrap() + .value(); + + assert_eq!(got_down.repr, want_down, "log2({sig}) base {B} under Down"); + assert_eq!(got_up.repr, want_up, "log2({sig}) base {B} under Up"); + assert_eq!(got_he.repr, want_he, "log2({sig}) base {B} under HalfEven"); + } + + #[test] + fn test_log2_directed_matches_oracle() { + let p = 24; + for sig in [3u32, 7, 10, 12345, 65537] { + check_log2_directed_matches_oracle::<2>(sig, p); + } + // Exercise a non-power-of-two base through the Ziv interval path too. + for sig in [3u32, 7, 10, 12345] { + check_log2_directed_matches_oracle::<10>(sig, p); + } + } }