Repository navigation
Fix nextafter returning inf instead of the largest finite value - #23417
Conversation
The tensorflow and torch backends cast both operands to float64, call nextafter there, then cast the result back. nextafter(inf, -inf) in float64 is the float64 maximum, which overflows back to inf in float32, so keras.ops.nextafter(inf, -inf) returned inf instead of 3.4028235e+38. The same round trip loses steps near zero, which are smaller than one float64-derived ulp once cast down. Both backends now compute in the result dtype. torch.nextafter supports float16, bfloat16, float32 and float64 natively. tf.math.nextafter supports only float32 and float64, so half precision is computed in float32 there. The numpy and jax backends already did this and were correct. Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request refactors the 'nextafter' implementation in the TensorFlow and PyTorch backends to improve dtype handling, and adds unit tests for edge cases such as stepping away from infinity and steps near zero. The review feedback identifies a critical mathematical issue in the TensorFlow backend where casting 16-bit floats ('float16' and 'bfloat16') to 'float32' and back results in incorrect values, underflow, or overflow. To address this, a bitcast-based implementation is suggested for 16-bit floats, along with expanding the new test cases to run on both 'float16' and 'float32' dtypes.
| # tf.math.nextafter only supports float32 and float64. | ||
| compute_dtype = dtype if dtype in ("float32", "float64") else "float32" | ||
| x1 = tf.cast(x1, compute_dtype) | ||
| x2 = tf.cast(x2, compute_dtype) | ||
| return tf.cast(tf.math.nextafter(x1, x2), dtype) |
There was a problem hiding this comment.
Computing nextafter for 16-bit float types (float16 and bfloat16) by casting to float32 and back is mathematically incorrect and results in wrong values or no change at all.
Why this happens:
-
Underflow/No-op for most values:
The step size offloat32is much smaller thanfloat16(e.g.,$2^{-23}$ vs$2^{-10}$ relative to the binade). When starting from afloat16value like1.0,tf.math.nextafter(1.0, 2.0)infloat32returns1.0000001192092896. When cast back tofloat16, this rounds right back to1.0instead of the correct nextfloat16value1.0009765625. Thus,nextafteracts as an identity function for almost all finite non-zerofloat16values. -
Overflow to Infinity:
Stepping away frominftowards-infinfloat16should land on65504. However, infloat32,nextafter(inf, -inf)returns3.4028235e+38. Casting this back tofloat16overflows back toinf. -
Underflow to Zero:
Stepping away from0.0towards1.0infloat16should land on5.96e-8. Infloat32, it lands on1.4e-45, which underflows to0.0when cast back tofloat16.
Solution:
We can implement nextafter for 16-bit floats using bit-level integer arithmetic via tf.bitcast and tf.where to perform a safe, exact 16-bit step.
| # tf.math.nextafter only supports float32 and float64. | |
| compute_dtype = dtype if dtype in ("float32", "float64") else "float32" | |
| x1 = tf.cast(x1, compute_dtype) | |
| x2 = tf.cast(x2, compute_dtype) | |
| return tf.cast(tf.math.nextafter(x1, x2), dtype) | |
| if dtype in ("float16", "bfloat16"): | |
| x1 = tf.cast(x1, dtype) | |
| x2 = tf.cast(x2, dtype) | |
| u1 = tf.bitcast(x1, tf.uint16) | |
| i1 = tf.cast(u1, tf.int32) | |
| sign_bit = 0x8000 | |
| mapped_i1 = tf.where((i1 & sign_bit) != 0, sign_bit - i1, i1) | |
| step = tf.where(x1 < x2, 1, tf.where(x1 > x2, -1, 0)) | |
| new_mapped = mapped_i1 + step | |
| new_i1 = tf.where(new_mapped >= 0, new_mapped, sign_bit - new_mapped) | |
| new_u1 = tf.cast(new_i1, tf.uint16) | |
| res = tf.bitcast(new_u1, dtype) | |
| nan_val = tf.cast(float("nan"), dtype) | |
| return tf.where(tf.math.is_nan(x1) | tf.math.is_nan(x2), nan_val, res) | |
| x1 = tf.cast(x1, dtype) | |
| x2 = tf.cast(x2, dtype) | |
| return tf.math.nextafter(x1, x2) |
There was a problem hiding this comment.
This was right, and it is fixed. Casting 16-bit floats to float32 and back is a no-op for most values, because a float32 step is much smaller than a 16-bit ulp and the result rounds straight back to x1.
The current code no longer does that. _nextafter_16bit steps the IEEE 754 bit pattern by one, which is exactly a one-ulp step in the target format. It also covers two cases the float32 round trip got wrong: zero has no bit predecessor, so it maps to the smallest subnormal carrying the sign of the direction travelled, and zero is detected on the bit pattern rather than with ==, because bfloat16 subnormals compare equal to zero in tf's comparison ops.
Verified exhaustively against np.nextafter: all 63,490 non-NaN float16 values against 7 targets, which is 444,430 comparisons, with 0 mismatches. All 65,536 bfloat16 bit patterns stepped toward both infinities also match.
The PR description still described the old approach, so I have updated it.
| # Stepping away from an infinity must land on the largest finite value | ||
| # of the result dtype rather than staying at infinity. | ||
| x = np.array([np.inf, -np.inf], dtype="float32") | ||
| y = np.array([-np.inf, np.inf], dtype="float32") | ||
| self.assertAllClose(knp.nextafter(x, y), np.nextafter(x, y)) | ||
|
|
||
| # Steps near zero are smaller than one float32 ulp of 1.0, so they are | ||
| # lost if the computation happens in a wider dtype and is cast back. | ||
| x = np.array([0.0, np.finfo("float32").tiny], dtype="float32") | ||
| y = np.array([1.0, 0.0], dtype="float32") | ||
| self.assertAllClose(knp.nextafter(x, y), np.nextafter(x, y)) |
There was a problem hiding this comment.
To prevent future regressions and ensure that the fix works correctly for both float16 and float32, we should run these edge-case tests across both dtypes.
| # Stepping away from an infinity must land on the largest finite value | |
| # of the result dtype rather than staying at infinity. | |
| x = np.array([np.inf, -np.inf], dtype="float32") | |
| y = np.array([-np.inf, np.inf], dtype="float32") | |
| self.assertAllClose(knp.nextafter(x, y), np.nextafter(x, y)) | |
| # Steps near zero are smaller than one float32 ulp of 1.0, so they are | |
| # lost if the computation happens in a wider dtype and is cast back. | |
| x = np.array([0.0, np.finfo("float32").tiny], dtype="float32") | |
| y = np.array([1.0, 0.0], dtype="float32") | |
| self.assertAllClose(knp.nextafter(x, y), np.nextafter(x, y)) | |
| for dtype in ("float16", "float32"): | |
| # Stepping away from an infinity must land on the largest finite value | |
| # of the result dtype rather than staying at infinity. | |
| x = np.array([np.inf, -np.inf], dtype=dtype) | |
| y = np.array([-np.inf, np.inf], dtype=dtype) | |
| self.assertAllClose(knp.nextafter(x, y), np.nextafter(x, y)) | |
| # Steps near zero are smaller than one float32/float16 ulp of 1.0, so they are | |
| # lost if the computation happens in a wider dtype and is cast back. | |
| x = np.array([0.0, np.finfo(dtype).tiny], dtype=dtype) | |
| y = np.array([1.0, 0.0], dtype=dtype) | |
| self.assertAllClose(knp.nextafter(x, y), np.nextafter(x, y)) |
There was a problem hiding this comment.
Taken, with one change. The edge cases now run on float16 as well.
I also dropped the tolerance to atol=0, rtol=0 on those assertions. These are exact one-ulp steps, and the default 1e-6 made the near-zero case vacuous: a float32 step from 0.0 is 1.4e-45, so assertAllClose passed even when the backend returned 0.0 unchanged, which is the exact bug the assertion was added to catch.
Confirmed the stricter test fails on the unfixed backend and passes on the fixed one.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #23417 +/- ##
==========================================
- Coverage 85.06% 80.71% -4.35%
==========================================
Files 477 477
Lines 71434 71453 +19
Branches 11823 11824 +1
==========================================
- Hits 60764 57676 -3088
- Misses 7652 10878 +3226
+ Partials 3018 2899 -119
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tf.math.nextafter accepts only float32 and float64, so the previous commit promoted float16 and bfloat16 to float32. That does not work either: a float32 step is far smaller than a 16-bit ulp, so the result rounds back to x1 and nextafter(1.0, 2.0) returned 1.0. Step the IEEE 754 bit pattern by one instead, which is what a one-ulp step is. Zero is detected on the bit pattern rather than with ==, because bfloat16 subnormals compare equal to zero in tf. Verified exhaustively against np.nextafter over all 63490 non-NaN float16 values across 7 targets, 444430 comparisons, zero mismatches. Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
|
Good catch, this was a real gap. Promoting 16-bit floats to float32 does not fix them: a float32 step is much smaller than a float16 ulp, so the result rounds back to x1 and nextafter(1.0, 2.0) returns 1.0. I have switched float16 and bfloat16 to step the IEEE 754 bit pattern directly, which is what a one-ulp step is. Verified against np.nextafter over every non-NaN float16 value, 63490 values across 7 targets, with no mismatches. One known limit: 254 bfloat16 subnormals are still wrong, because bfloat16 subnormals compare equal to zero in tf's comparison ops so the direction cannot be recovered there. Previously every bfloat16 value was wrong, so this is a strict improvement, but I did not want to claim it is complete. |
|
Hi @UgaTheDev , Can you kindly sign the CLA? Thank you! |
openvino's nextafter lacks dtype-aware ulp stepping for 16-bit floats (pre-existing; out of scope for this PR). Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
|
@keerthanakadiri CLA is signed, the Also worth flagging for the review above: I pushed a follow-up that switches float16 and bfloat16 to step the IEEE 754 bit pattern directly rather than promoting to float32, since a float32 step is smaller than a 16-bit ulp and rounds back to the input. Verified against |
The infinity and near-zero cases only ran on float32, and used the default tolerance. Both are exact one-ulp steps, so the default atol of 1e-6 passed even when a backend returned x1 unchanged: a float32 step from 0.0 is 1.4e-45, far below the tolerance meant to catch it. Run them over float16 as well, where the wider-dtype round trip is most visible, and compare with atol=0, rtol=0.
openvino computes nextafter in float64 and casts the result back, so any step smaller than a float64 ulp is lost and subnormals are flushed to zero. Both exact-ulp assertions therefore return x1 unchanged there, and a float16 infinity stays infinite because the float64 result overflows the cast back. Only the step away from a float32 infinity is meaningful on openvino, so assert just that one, which is the case this PR fixes. Replace the float16-specific skip, whose stated reason was too narrow, with a single guard covering both exact-ulp cases. Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
|
The openvino failure was mine. openvino's That affects more than the float16 case I'd already skipped. On float32, openvino returns So openvino now asserts exactly that one case, which is the regression this PR fixes, and both The earlier |
|
@gbaned this is green across all five backends (15/15 checks) and mergeable — anything else needed from my side? One update since your assignment: the openvino carve-out this PR had to make turned out to be a fixable backend defect (float64-width ULP steps vanishing on the cast to narrower dtypes), and I've now fixed that separately in #23520, stacked on this branch. Merging this one first lets #23520 rebase down to just the openvino fix. |
The openvino nextafter stepped by a float64 ULP and cast the result back to the result dtype, so any step narrower than a float64 ULP was lost in that cast and nextafter returned x1 unchanged. On float32 that made nextafter(1.0, 2.0) return 1.0 instead of 1.0000001. The three float64 constants driving the step -- 52 mantissa bits, a -1022 minimum exponent and the 5e-324 smallest subnormal -- are now taken from the result dtype instead, which leaves float64 behaviour identical and makes narrower dtypes step by their own ULP. Stepping inwards from an infinity is selected explicitly, because log2 of an infinity is not exact and the previous float64-width step only happened to round back to the largest finite float32. float32 and float16 now take the exact-ULP assertions added in keras-team#23417 on openvino. The step out of zero is still skipped there: both results are subnormal and openvino's compiled float64 -> narrower conversion flushes subnormals to zero however the step is computed. Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
hertschuh
left a comment
There was a problem hiding this comment.
Thanks for addressing this!
| openvino = backend.backend() == "openvino" | ||
| dtypes = ["float32"] if openvino else ["float32", "float16"] | ||
| for dtype in dtypes: |
There was a problem hiding this comment.
Instead of this:
- please make
test_nextaftertest parameterized withdtypeas a parameter - instead of hardcoding the skipping of openvino like this, please exclude the test
test_nextafter_bfloat16in this file: https://github.com/keras-team/keras/blob/master/keras/src/backend/openvino/excluded_concrete_tests.txt
There was a problem hiding this comment.
Done in 406d68e: test_nextafter is now @parameterized.named_parameters over float32 and float16, and the openvino skip is gone from the test body. NumpyTwoInputOpsCorrectnessTest::test_nextafter_float16 and ::test_nextafter_float32 are added to excluded_concrete_tests.txt (numpy has no bfloat16 nextafter, so there is no bfloat16 case to exclude). Both had to go: on openvino the near-zero and one-ulp steps land back on x1 for float32 as well, as noted in the earlier comment. Passes on the numpy and jax backends locally (125 selected tests each).
| # These are exact one-ulp steps, so they are compared exactly. The | ||
| # default tolerance would pass even if a backend returned x1 unchanged, | ||
| # because the values involved are far smaller than it. | ||
| # | ||
| # openvino steps in float64 and casts the result back, so a step | ||
| # smaller than a float64 ulp is lost and subnormals are flushed to | ||
| # zero. Only the step away from a float32 infinity survives that, so | ||
| # it is the only case asserted there: a float16 infinity stays | ||
| # infinite because the float64 result overflows the cast back, and | ||
| # both exact-ulp steps land back on x1 (pre-existing; out of scope for | ||
| # this PR). |
There was a problem hiding this comment.
This information is not needed outside of the context of this PR. So remove this, or replace it with a one-liner like this:
# `atol` and `rtol` must be zero because the differences are so small.
There was a problem hiding this comment.
Done in 406d68e, replaced with the one-liner.
Review feedback: test_nextafter is now parameterized over float32 and float16 instead of looping, the PR-specific explanatory comment is replaced by a one-liner, and the openvino skip moves out of the test body into excluded_concrete_tests.txt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qnxf2u2kSBxRM1AT7kD8BD Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
…s-team#23417) * Fix nextafter returning inf instead of the largest finite value The tensorflow and torch backends cast both operands to float64, call nextafter there, then cast the result back. nextafter(inf, -inf) in float64 is the float64 maximum, which overflows back to inf in float32, so keras.ops.nextafter(inf, -inf) returned inf instead of 3.4028235e+38. The same round trip loses steps near zero, which are smaller than one float64-derived ulp once cast down. Both backends now compute in the result dtype. torch.nextafter supports float16, bfloat16, float32 and float64 natively. tf.math.nextafter supports only float32 and float64, so half precision is computed in float32 there. The numpy and jax backends already did this and were correct. Signed-off-by: Kush Zingade <kush.zingade@gmail.com> * Compute nextafter for 16-bit floats by stepping the bit pattern tf.math.nextafter accepts only float32 and float64, so the previous commit promoted float16 and bfloat16 to float32. That does not work either: a float32 step is far smaller than a 16-bit ulp, so the result rounds back to x1 and nextafter(1.0, 2.0) returned 1.0. Step the IEEE 754 bit pattern by one instead, which is what a one-ulp step is. Zero is detected on the bit pattern rather than with ==, because bfloat16 subnormals compare equal to zero in tf. Verified exhaustively against np.nextafter over all 63490 non-NaN float16 values across 7 targets, 444430 comparisons, zero mismatches. Signed-off-by: Kush Zingade <kush.zingade@gmail.com> * Skip float16 nextafter assertion on openvino backend openvino's nextafter lacks dtype-aware ulp stepping for 16-bit floats (pre-existing; out of scope for this PR). Signed-off-by: Kush Zingade <kush.zingade@gmail.com> * Test nextafter edge cases on float16 as well, and compare exactly The infinity and near-zero cases only ran on float32, and used the default tolerance. Both are exact one-ulp steps, so the default atol of 1e-6 passed even when a backend returned x1 unchanged: a float32 step from 0.0 is 1.4e-45, far below the tolerance meant to catch it. Run them over float16 as well, where the wider-dtype round trip is most visible, and compare with atol=0, rtol=0. * Assert only the infinity step of nextafter on openvino openvino computes nextafter in float64 and casts the result back, so any step smaller than a float64 ulp is lost and subnormals are flushed to zero. Both exact-ulp assertions therefore return x1 unchanged there, and a float16 infinity stays infinite because the float64 result overflows the cast back. Only the step away from a float32 infinity is meaningful on openvino, so assert just that one, which is the case this PR fixes. Replace the float16-specific skip, whose stated reason was too narrow, with a single guard covering both exact-ulp cases. Signed-off-by: Kush Zingade <kush.zingade@gmail.com> * Parameterize test_nextafter by dtype and exclude it on openvino Review feedback: test_nextafter is now parameterized over float32 and float16 instead of looping, the PR-specific explanatory comment is replaced by a one-liner, and the openvino skip moves out of the test body into excluded_concrete_tests.txt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qnxf2u2kSBxRM1AT7kD8BD Signed-off-by: Kush Zingade <kush.zingade@gmail.com> --------- Signed-off-by: Kush Zingade <kush.zingade@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: hertschuh <1091026+hertschuh@users.noreply.github.com> (cherry picked from commit 9501b95)
…s-team#23417) (cherry picked from commit 9501b95)
…s-team#23417) (cherry picked from commit 9501b95)
* Broadcast single-pair pad_width to all axes in ops.pad (#23180) (cherry picked from commit 1463195) * Fix sparsemax threshold when more than one logit is in the support (#23469) (cherry picked from commit 569d569) * Honour keepdims when axis is None in the torch backend reductions (#23535) (cherry picked from commit e43f1fe) * [Fix] keras.ops inconsistently downcasts float64 to float32 (#23225) (cherry picked from commit aedb8a3) * Add float_power to keras.ops.numpy (#23500) (cherry picked from commit 7c06497) * Fix nextafter returning inf instead of the largest finite value (#23417) (cherry picked from commit 9501b95) * Add copysign to keras.ops.numpy (#23498) (cherry picked from commit 699e480) * Implement cov function in keras.ops (#23494) (cherry picked from commit 031c73d) * Fix reductions dropping rank with axis=None, keepdims=True (#23571) (cherry picked from commit 3d9dfcc) --------- Co-authored-by: rstar327 <rockstar00327@outlook.com> Co-authored-by: Marcos Ashton <59975445+MarcosAsh@users.noreply.github.com> Co-authored-by: __Sid__004 <146583369+Sid-5137@users.noreply.github.com> Co-authored-by: Chirag Wattamwar <119049661+ChiragSW@users.noreply.github.com> Co-authored-by: Pratyay Banerjee <putubanerjee23@gmail.com> Co-authored-by: Kush Zingade <kush.zingade@gmail.com> Co-authored-by: AbdulAliMamnun <54176945+AbdulAliMamnun@users.noreply.github.com>
The openvino nextafter stepped by a float64 ULP and cast the result back to the result dtype, so any step narrower than a float64 ULP was lost in that cast and nextafter returned x1 unchanged. On float32 that made nextafter(1.0, 2.0) return 1.0 instead of 1.0000001. The three float64 constants driving the step -- 52 mantissa bits, a -1022 minimum exponent and the 5e-324 smallest subnormal -- are now taken from the result dtype instead, which leaves float64 behaviour identical and makes narrower dtypes step by their own ULP. Stepping inwards from an infinity is selected explicitly, because log2 of an infinity is not exact and the previous float64-width step only happened to round back to the largest finite float32. float32 and float16 now take the exact-ULP assertions added in keras-team#23417 on openvino. The step out of zero is still skipped there: both results are subnormal and openvino's compiled float64 -> narrower conversion flushes subnormals to zero however the step is computed. Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
* Step nextafter by a ULP of the result dtype on openvino The openvino nextafter stepped by a float64 ULP and cast the result back to the result dtype, so any step narrower than a float64 ULP was lost in that cast and nextafter returned x1 unchanged. On float32 that made nextafter(1.0, 2.0) return 1.0 instead of 1.0000001. The three float64 constants driving the step -- 52 mantissa bits, a -1022 minimum exponent and the 5e-324 smallest subnormal -- are now taken from the result dtype instead, which leaves float64 behaviour identical and makes narrower dtypes step by their own ULP. Stepping inwards from an infinity is selected explicitly, because log2 of an infinity is not exact and the previous float64-width step only happened to round back to the largest finite float32. float32 and float16 now take the exact-ULP assertions added in #23417 on openvino. The step out of zero is still skipped there: both results are subnormal and openvino's compiled float64 -> narrower conversion flushes subnormals to zero however the step is computed. Signed-off-by: Kush Zingade <kush.zingade@gmail.com> * ci: retrigger checks after contributor-agreement update Signed-off-by: Kush Zingade <kush.zingade@gmail.com> * Address review: simplify nextafter test, fix ULP halving at minexp Drop the dtype loop and the PR-specific comment from test_nextafter; the parameterized dtype already covers float32 and float16. Stop halving the ULP when stepping towards zero from the smallest normal: the subnormal range below it has the same spacing, so the halved step landed on a rounding midpoint and nextafter(tiny, 0) returned tiny on float16. Run test_nextafter on openvino again. Only the near-zero case is skipped, and only for float32: the openvino CPU plugin flushes float32 subnormals to zero in constant-only graphs, which is how eager ops are evaluated. Signed-off-by: Kush Zingade <kush.zingade@gmail.com> * Move the near-zero nextafter case into its own test Exclude test_nextafter_tiny_float32 on openvino through the exclusions file instead of branching on the backend inside the test. Signed-off-by: Kush Zingade <kush.zingade@gmail.com> --------- Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
Fixes #23132
What is broken
keras.ops.nextafter(inf, -inf)returnsinfon the tensorflow and torch backends instead of the largest finite float32,3.4028235e+38. The numpy and jax backends return the correct value.Both backends cast the operands to float64, call
nextafterthere, then cast back:nextafter(inf, -inf)in float64 is1.7976931348623157e+308, the float64 maximum. Casting that to float32 overflows back toinf.The same round trip also loses steps near zero. A float32 step from
0.0toward1.0is smaller than the float64 result can represent once it is cast down.Fix
Compute in the result dtype instead of round tripping through float64.
torch.nextaftersupports float16, bfloat16, float32 and float64 natively, so the torch backend now casts to the result dtype and returns directly.tf.math.nextafteraccepts only float32 and float64 (Value for attr 'T' of half is not in the list of allowed values: double, float), so float32 and float64 go straight to it.float16 and bfloat16 cannot be handled by computing in float32 and casting back. A float32 step is far smaller than a 16-bit ulp, so the result rounds straight back to
x1and the op becomes a no-op._nextafter_16bitsteps the IEEE 754 bit pattern by one instead, which is exactly a one-ulp step in the target format. Zero is special cased, since it has no bit predecessor, to the smallest subnormal carrying the sign of the direction travelled. Zero is also detected on the bit pattern rather than with==, because bfloat16 subnormals compare equal to zero in tf's comparison ops.Tests
test_nextafteris extended with a step away from each infinity, a step near zero, and an ordinary one-ulp step, each run on float32 and float16 and compared againstnp.nextafter.These are exact operations, so they are compared with
atol=0, rtol=0. The default tolerance of1e-6would have passed even if a backend returnedx1unchanged, because a float32 step from0.0is1.4e-45, far below the tolerance meant to catch it.Beyond the test suite, the tensorflow 16-bit path was verified exhaustively against
np.nextafter: all 63,490 non-NaN float16 values against 7 targets, which is 444,430 comparisons, with 0 mismatches. All 65,536 bfloat16 bit patterns stepped toward both infinities also match.Verified fail-before and pass-after on both backends:
NumpyDtypeTest::test_nextafter_*could not be run locally because those cases importjaxfor the expected dtype and jax is not installed in this environment. The change preserves the returned dtype in every path, so dtype behaviour is unchanged.Not covered
The openvino backend has the same float64 round trip, but its
nextafteris hand written bit manipulation on f64 constants rather than a call to a native op, and I could not exercise that backend locally. I have left it alone rather than rewrite it untested. Happy to follow up in a separate PR if you would like it covered.Contributor Agreement