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}