Skip to main content

dashu_float/
ziv.rs

1//! The Ziv retry loop — guaranteed-correct rounding for transcendentals.
2//!
3//! Transcendentals (`exp`, `ln`, …) cannot compute an exact result the way arithmetic can,
4//! so they approximate at a working precision `p + guard` and round down. A single guard-digit
5//! heuristic is only *near*-correct: for an input whose true value sits on a rounding boundary,
6//! the rounded result can be off by one ULP. The Ziv loop closes that gap.
7//!
8//! Each transcendental reports its approximation together with a provable absolute error radius
9//! `E` (a true upper bound on `|approx − true|`, built from the fact that every `+`/`-`/`*`/`/`
10//! in the algorithm is itself correctly rounded). The driver rounds the approximation to the
11//! target precision and checks whether the entire error interval `[ã − E, ã + E]` lies inside
12//! the candidate's [`ErrorBounds`] preimage — the set of reals that round to it. If it does,
13//! the rounded value is *guaranteed* correct; otherwise the driver retries with more guard
14//! digits. The loop provably terminates (a true tie is resolved deterministically by the mode),
15//! with a large sanity cap as an unreachable backstop.
16
17use core::cmp::Ordering;
18
19use dashu_base::Approximation::*;
20
21use crate::{
22    error::{FpError, FpResult},
23    fbig::FBig,
24    repr::{Context, Repr},
25    round::ErrorBounds,
26};
27use dashu_int::Word;
28
29/// Maximum number of Ziv retries before falling back to the best-effort rounded value.
30///
31/// This is a sanity backstop only — the loop converges as soon as the working precision is
32/// large enough that the error interval no longer straddles a rounding boundary, which happens
33/// in one attempt for essentially all inputs (the guard-digit heuristic is sized for that) and
34/// in a handful of attempts only for inputs pathological close to a tie. The cap exists so a
35/// bug in an error-radius bound can never produce an infinite loop.
36const MAX_ZIV_RETRIES: usize = 32;
37
38// A test-only retry counter, so tests can assert the loop converges on the first attempt for
39// typical inputs (validating that the guard-digit heuristic wasn't over-tightened). Reads as
40// the number of *extra* attempts beyond the first, i.e. `0` means first-attempt success.
41#[cfg(any(all(test, feature = "std"), feature = "tuning"))]
42thread_local! {
43    pub(crate) static LAST_ZIV_RETRIES: core::cell::Cell<usize> = const { core::cell::Cell::new(0) };
44}
45
46// Reset/bump the retry counter, with no-op fallbacks when the `tuning` feature (or test mode)
47// is absent — the Ziv loop body stays clean.
48#[cfg(any(all(test, feature = "std"), feature = "tuning"))]
49fn ziv_retries_reset_impl() {
50    LAST_ZIV_RETRIES.with(|c| c.set(0));
51}
52#[cfg(not(any(all(test, feature = "std"), feature = "tuning")))]
53fn ziv_retries_reset_impl() {}
54
55#[cfg(any(all(test, feature = "std"), feature = "tuning"))]
56fn ziv_retries_bump() {
57    LAST_ZIV_RETRIES.with(|c| c.set(c.get().saturating_add(1)));
58}
59#[cfg(not(any(all(test, feature = "std"), feature = "tuning")))]
60fn ziv_retries_bump() {}
61
62/// Number of *extra* Ziv attempts beyond the first in the most recent Ziv loop (0 = first-attempt
63/// success). Profiling only (via the `tuning` feature) — available when the `tuning` feature (or `cfg(test)`) is enabled.
64#[cfg(any(all(test, feature = "std"), feature = "tuning"))]
65pub fn ziv_retries() -> usize {
66    LAST_ZIV_RETRIES.with(|c| c.get())
67}
68
69/// Reset the retry counter to 0 (before a measurement, so exact short-circuits that never enter a
70/// Ziv loop report 0 rather than the previous call's count).
71#[cfg(any(all(test, feature = "std"), feature = "tuning"))]
72pub fn ziv_retries_reset() {
73    ziv_retries_reset_impl();
74}
75
76impl<R: ErrorBounds> Context<R> {
77    /// Correctly round a transcendental approximation to this context's precision using a Ziv
78    /// retry loop.
79    ///
80    /// `approx(guard)` computes the function at working precision `self.precision + guard` and
81    /// returns `Ok((value, error_radius))` — the value and a provable upper bound on its absolute
82    /// error, both as [`FBig`]s at the working context — or an [`FpError`] when the computation
83    /// over/underflows the finite range mid-attempt. The driver propagates that error immediately
84    /// (on the first overflowing attempt), so a closure that can detect overflow *as it computes*
85    /// (e.g. `powi`'s squaring chain, whose `sqr`/`mul` hit the `±isize::MAX` exponent sentinel)
86    /// need not pre-probe; a closure that cannot overflow simply never returns `Err`. The closure is
87    /// expected to capture and reborrow any [`ConstCache`](crate::ConstCache) from the enclosing
88    /// scope; the driver calls it once per attempt and grows `guard` when the result cannot be
89    /// certified.
90    ///
91    /// The loop preserves the [`Exact`]/[`Inexact`] flag from rounding the approximation to the
92    /// target precision.
93    pub(crate) fn ziv<const B: Word>(
94        &self,
95        initial_guard: usize,
96        mut approx: impl FnMut(usize) -> Result<(FBig<R, B>, FBig<R, B>), FpError>,
97    ) -> FpResult<FBig<R, B>> {
98        // Unlimited precision: the approximation is exact, so report it as-is.
99        if !self.is_limited() {
100            let (value, _err) = approx(0)?;
101            return Ok(Exact(value));
102        }
103
104        let mut guard = initial_guard;
105        ziv_retries_reset_impl();
106        for _ in 0..MAX_ZIV_RETRIES {
107            let (a, e) = approx(guard)?;
108            // `with_precision` consumes `a`, but the containment test still needs it, so round a
109            // clone and keep the original for the interval check.
110            let candidate = a.clone().with_precision(self.precision);
111            if Self::contained::<B>(&a.repr, &e.repr, candidate.value_ref()) {
112                return Ok(candidate);
113            }
114
115            // Grow the guard aggressively so a near-tie resolves in a couple of retries, while
116            // the first attempt (with the heuristic guard) handles the common case. Cap it so
117            // `guard` (and the closure's working precision `p + guard`) can't overflow `usize` —
118            // reachable only if a radius-bound bug drives the loop to its retry cap.
119            let step = core::cmp::max(guard, self.precision / 2).max(1);
120            guard = guard.saturating_add(step).min(usize::MAX - self.precision);
121            ziv_retries_bump();
122        }
123
124        // Unreachable in practice: a radius-bound bug would otherwise loop forever. Report it
125        // instead of silently returning a possibly-1-ULP-wrong best-effort candidate.
126        Err(FpError::ZivRetryLimitExceeded)
127    }
128
129    /// Pair variant of [`ziv`](Self::ziv) for functions that return two values (e.g. `sin_cos`,
130    /// `sinh_cosh`). `approx(guard)` returns `Ok(((v1, e1), (v2, e2)))` — both values and their
131    /// provable radii at the working context, sharing whatever computation is common — or an
132    /// [`FpError`], propagated to both slots as `(Err(e), Err(e))`. The driver certifies **both**
133    /// values: it retries while *either* containment test fails, and returns both only when both fit
134    /// their rounding preimages. Shares the guard-growth loop and retry counter with [`ziv`](Self::ziv).
135    pub(crate) fn ziv_pair<const B: Word>(
136        &self,
137        initial_guard: usize,
138        mut approx: impl FnMut(
139            usize,
140        )
141            -> Result<((FBig<R, B>, FBig<R, B>), (FBig<R, B>, FBig<R, B>)), FpError>,
142    ) -> (FpResult<FBig<R, B>>, FpResult<FBig<R, B>>) {
143        // Unlimited precision: both approximations are exact, report them as-is.
144        if !self.is_limited() {
145            let ((v1, _), (v2, _)) = match approx(0) {
146                Ok(v) => v,
147                Err(e) => return (Err(e), Err(e)),
148            };
149            return (Ok(Exact(v1)), Ok(Exact(v2)));
150        }
151
152        let mut guard = initial_guard;
153        ziv_retries_reset_impl();
154        for _ in 0..MAX_ZIV_RETRIES {
155            let ((a1, e1), (a2, e2)) = match approx(guard) {
156                Ok(v) => v,
157                Err(e) => return (Err(e), Err(e)),
158            };
159            let c1 = a1.clone().with_precision(self.precision);
160            let c2 = a2.clone().with_precision(self.precision);
161            if Self::contained::<B>(&a1.repr, &e1.repr, c1.value_ref())
162                && Self::contained::<B>(&a2.repr, &e2.repr, c2.value_ref())
163            {
164                return (Ok(c1), Ok(c2));
165            }
166
167            // Grow the guard aggressively so a near-tie resolves in a couple of retries. Cap it so
168            // `guard` (and the closure's working precision `p + guard`) can't overflow `usize`.
169            let step = core::cmp::max(guard, self.precision / 2).max(1);
170            guard = guard.saturating_add(step).min(usize::MAX - self.precision);
171            ziv_retries_bump();
172        }
173
174        // Unreachable in practice: a radius-bound bug would otherwise loop forever. Report it
175        // instead of silently returning possibly-1-ULP-wrong best-effort candidates.
176        (Err(FpError::ZivRetryLimitExceeded), Err(FpError::ZivRetryLimitExceeded))
177    }
178
179    /// Containment test: is the approximation's error interval `[a − e, a + e]` entirely inside
180    /// the rounding preimage of `y` (every real in `[y − lb, y + rb]` rounds to `y` under `R`)?
181    ///
182    /// `a` and `e` are the working-precision approximation and its provable error radius; `y` is
183    /// the candidate rounded to the target precision (kept as an [`FBig`] only because
184    /// [`ErrorBounds::error_bounds`] is defined on [`FBig`]). The interval arithmetic runs on the
185    /// raw [`Repr`]s, which carry no precision limit, so the additions are lossless — there is no
186    /// rounding that could drop a guard digit and mis-decide (a wrong call here yields a wrong
187    /// ULP). The old path promoted every value to unlimited precision via `with_precision(0)`;
188    /// the [`Repr`]s are already exact, so that was a chain of no-op clones, now removed.
189    ///
190    /// The test compares sums rather than differences — algebraically identical for exact
191    /// arithmetic, and it reads as a single shared inequality per endpoint:
192    ///   `a − e ≥ y − lb  ⟺  a + lb ≥ y + e`
193    ///   `a + e ≤ y + rb  ⟺  y + rb ≥ a + e`
194    fn contained<const B: Word>(a: &Repr<B>, e: &Repr<B>, y: &FBig<R, B>) -> bool {
195        let (lb, rb, incl_l, incl_r) = R::error_bounds::<B>(y);
196
197        let y = &y.repr;
198        let lb = lb.into_repr();
199        let rb = rb.into_repr();
200
201        let left = (a + &lb).cmp(&(y + e));
202        let right = (y + &rb).cmp(&(a + e));
203        let left_ok = if incl_l {
204            left != Ordering::Less
205        } else {
206            left == Ordering::Greater
207        };
208        let right_ok = if incl_r {
209            right != Ordering::Less
210        } else {
211            right == Ordering::Greater
212        };
213        left_ok && right_ok
214    }
215}
216
217// The tests read `LAST_ZIV_RETRIES` (a `thread_local`), which only exists under `std` — gating the
218// module on `all(test, std)` (rather than just `test`) keeps no-std test builds compiling.
219#[cfg(all(test, feature = "std"))]
220mod tests {
221    use super::*;
222    use crate::round::mode;
223
224    type F = crate::FBig<mode::HalfEven>;
225
226    // An exact approximation (radius 0) is accepted on the first attempt as Exact.
227    #[test]
228    fn ziv_accepts_exact_first_attempt() {
229        let ctx: Context<mode::HalfEven> = Context::new(10);
230        LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX));
231        let r = ctx.ziv(4, |_| Ok((F::ONE, F::ZERO)));
232        assert!(matches!(r, Ok(Exact(_))));
233        assert_eq!(LAST_ZIV_RETRIES.with(|c| c.get()), 0);
234    }
235
236    // An approximation whose error interval straddles a rounding boundary must retry; once the
237    // shrinking radius makes the interval unambiguous, ziv accepts.
238    #[test]
239    fn ziv_retries_until_contained() {
240        let ctx: Context<mode::HalfEven> = Context::new(4);
241        let r = ctx.ziv(2, |guard| {
242            // value 1.0, radius 2^(-guard): large on the first attempt, tiny later.
243            Ok((F::ONE, F::ONE >> guard as isize))
244        });
245        let _ = r.unwrap().value();
246        assert!(LAST_ZIV_RETRIES.with(|c| c.get()) >= 1);
247    }
248
249    // Unlimited-precision context short-circuits to a single Exact call (counter untouched).
250    #[test]
251    fn ziv_unlimited_short_circuits() {
252        let ctx: Context<mode::HalfEven> = Context::new(0);
253        LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX));
254        let r = ctx.ziv(4, |_| Ok((F::from(7u8), F::ZERO)));
255        assert!(matches!(r, Ok(Exact(_))));
256        assert_eq!(LAST_ZIV_RETRIES.with(|c| c.get()), usize::MAX);
257    }
258
259    // A closure that overflows on the first attempt propagates the error immediately (no retries).
260    #[test]
261    fn ziv_propagates_closure_error() {
262        let ctx: Context<mode::HalfEven> = Context::new(10);
263        let r = ctx.ziv::<2>(4, |_| Err(FpError::OutOfDomain));
264        assert_eq!(r, Err(FpError::OutOfDomain));
265    }
266
267    // An approximation whose error interval always straddles a rounding boundary (a radius-bound
268    // bug) exhausts the retry budget and reports `ZivRetryLimitExceeded` instead of silently
269    // returning a possibly-1-ULP-wrong best-effort value.
270    #[test]
271    fn ziv_reports_retry_limit_exceeded() {
272        let ctx: Context<mode::HalfEven> = Context::new(4);
273        // radius 10 >> 1 ulp at any working precision, so containment always fails.
274        let r = ctx.ziv(2, |_| Ok((F::ONE, F::from(10u8))));
275        assert_eq!(r, Err(FpError::ZivRetryLimitExceeded));
276        // the pair variant reports it on both slots.
277        let (r1, r2) = ctx.ziv_pair(2, |_| Ok(((F::ONE, F::from(10u8)), (F::ONE, F::ZERO))));
278        assert_eq!(r1, Err(FpError::ZivRetryLimitExceeded));
279        assert_eq!(r2, Err(FpError::ZivRetryLimitExceeded));
280    }
281
282    // ziv_pair accepts an exact pair (both radii 0) on the first attempt.
283    #[test]
284    fn ziv_pair_accepts_exact_first_attempt() {
285        let ctx: Context<mode::HalfEven> = Context::new(10);
286        LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX));
287        let (r1, r2) = ctx.ziv_pair(4, |_| Ok(((F::ONE, F::ZERO), (F::from(2u8), F::ZERO))));
288        assert!(matches!(r1, Ok(Exact(_))));
289        assert!(matches!(r2, Ok(Exact(_))));
290        assert_eq!(LAST_ZIV_RETRIES.with(|c| c.get()), 0);
291    }
292
293    // ziv_pair retries while *either* value's interval straddles a boundary; here the second value
294    // carries the shrinking radius, so the pair must retry together.
295    #[test]
296    fn ziv_pair_retries_until_both_contained() {
297        let ctx: Context<mode::HalfEven> = Context::new(4);
298        let (r1, r2) = ctx.ziv_pair(2, |guard| {
299            let radius = F::ONE >> guard as isize;
300            Ok(((F::ONE, F::ZERO), (F::ONE, radius)))
301        });
302        let _ = (r1.unwrap().value(), r2.unwrap().value());
303        assert!(LAST_ZIV_RETRIES.with(|c| c.get()) >= 1);
304    }
305
306    // ziv_pair propagates a closure error to *both* slots on the first overflowing attempt.
307    #[test]
308    fn ziv_pair_propagates_closure_error() {
309        let ctx: Context<mode::HalfEven> = Context::new(10);
310        let (r1, r2) = ctx.ziv_pair::<2>(4, |_| Err(FpError::OutOfDomain));
311        assert_eq!(r1, Err(FpError::OutOfDomain));
312        assert_eq!(r2, Err(FpError::OutOfDomain));
313    }
314
315    // The guard-digit heuristic should let exp/ln converge in at most one retry for typical
316    // inputs. A single retry on a near-tie is by design (Ziv certifies correctness; the guard only
317    // controls the first-attempt hit rate). This catches gross guard mis-sizing (many retries),
318    // not the occasional near-tie retry.
319    #[test]
320    fn ziv_few_retries_for_typical_inputs() {
321        let cases = [
322            F::try_from(0.5f64).unwrap(),
323            F::try_from(1.5f64).unwrap(),
324            F::try_from(2.0f64).unwrap(),
325            F::try_from(10.0f64).unwrap(),
326            F::try_from(1000.0f64).unwrap(),
327            F::try_from(1e-6f64).unwrap(),
328        ];
329        const MAX_RETRIES: usize = 1;
330        for p in [10usize, 24, 53, 100, 200] {
331            for x in &cases {
332                let x = x.clone().with_precision(p).value();
333                LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX));
334                let _ = x.ln();
335                let ln_retries = LAST_ZIV_RETRIES.with(|c| c.get());
336                assert!(
337                    ln_retries <= MAX_RETRIES,
338                    "ln({x}) at p={p} took {ln_retries} retries (expected <= {MAX_RETRIES})"
339                );
340                LAST_ZIV_RETRIES.with(|c| c.set(usize::MAX));
341                let _ = x.exp();
342                let exp_retries = LAST_ZIV_RETRIES.with(|c| c.get());
343                assert!(
344                    exp_retries <= MAX_RETRIES,
345                    "exp({x}) at p={p} took {exp_retries} retries (expected <= {MAX_RETRIES})"
346                );
347            }
348        }
349    }
350}