Skip to content

Fix nextafter returning inf instead of the largest finite value - #23417

Merged
hertschuh merged 7 commits into
keras-team:masterfrom
UgaTheDev:fix-nextafter-inf
Sep 9, 2026
Merged

hertschuh merged 7 commits into
keras-team:masterfrom
UgaTheDev:fix-nextafter-inf

Conversation

@UgaTheDev

@UgaTheDev UgaTheDev commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23132

What is broken

keras.ops.nextafter(inf, -inf) returns inf on 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 nextafter there, then cast back:

dtype = dtypes.result_type(x1.dtype, x2.dtype, float)
x1 = tf.cast(x1, tf.float64)
x2 = tf.cast(x2, tf.float64)
return tf.cast(tf.math.nextafter(x1, x2), dtype)

nextafter(inf, -inf) in float64 is 1.7976931348623157e+308, the float64 maximum. Casting that to float32 overflows back to inf.

The same round trip also loses steps near zero. A float32 step from 0.0 toward 1.0 is 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.nextafter supports float16, bfloat16, float32 and float64 natively, so the torch backend now casts to the result dtype and returns directly.

tf.math.nextafter accepts 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 x1 and the op becomes a no-op. _nextafter_16bit steps 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_nextafter is 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 against np.nextafter.

These are exact operations, so they are compared with atol=0, rtol=0. The default tolerance of 1e-6 would have passed even if a backend returned x1 unchanged, because a float32 step from 0.0 is 1.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:

# with the fix
KERAS_BACKEND=tensorflow pytest keras/src/ops/numpy_test.py -k "nextafter and not NumpyDtypeTest"  -> 3 passed
KERAS_BACKEND=torch      pytest keras/src/ops/numpy_test.py -k "nextafter and not NumpyDtypeTest"  -> 3 passed

# with only the backend changes reverted, new test kept
KERAS_BACKEND=tensorflow  -> 1 failed, 2 passed
KERAS_BACKEND=torch       -> 1 failed, 2 passed

NumpyDtypeTest::test_nextafter_* could not be run locally because those cases import jax for 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 nextafter is 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

  • I am a human, and not a bot.
  • I will be responsible for responding to review comments in a timely manner.
  • I will work with the maintainers to push this PR forward until submission.

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>
@google-cla

google-cla Bot commented Aug 10, 2026

Copy link
Copy Markdown

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread keras/src/backend/tensorflow/numpy.py Outdated
Comment on lines 3555 to 3559
# 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:

  1. Underflow/No-op for most values:
    The step size of float32 is much smaller than float16 (e.g., $2^{-23}$ vs $2^{-10}$ relative to the binade). When starting from a float16 value like 1.0, tf.math.nextafter(1.0, 2.0) in float32 returns 1.0000001192092896. When cast back to float16, this rounds right back to 1.0 instead of the correct next float16 value 1.0009765625. Thus, nextafter acts as an identity function for almost all finite non-zero float16 values.
  2. Overflow to Infinity:
    Stepping away from inf towards -inf in float16 should land on 65504. However, in float32, nextafter(inf, -inf) returns 3.4028235e+38. Casting this back to float16 overflows back to inf.
  3. Underflow to Zero:
    Stepping away from 0.0 towards 1.0 in float16 should land on 5.96e-8. In float32, it lands on 1.4e-45, which underflows to 0.0 when cast back to float16.

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.

Suggested change
# 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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread keras/src/ops/numpy_test.py Outdated
Comment on lines +4622 to +4632
# 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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
# 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))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-commenter

codecov-commenter commented Aug 10, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.71%. Comparing base (60b2071) to head (00ae3e2).
⚠️ Report is 1 commits behind head on master.

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     
Flag Coverage Δ
keras 80.52% <100.00%> (-4.34%) ⬇️
keras-cpu 79.79% <100.00%> (-4.34%) ⬇️
keras-gpu 70.06% <100.00%> (+<0.01%) ⬆️
keras-jax 58.71% <3.84%> (-0.02%) ⬇️
keras-numpy ?
keras-openvino 59.71% <3.84%> (-0.02%) ⬇️
keras-tensorflow 60.33% <76.92%> (+<0.01%) ⬆️
keras-torch 59.91% <26.92%> (-0.02%) ⬇️
keras-tpu 57.55% <3.84%> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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>
@UgaTheDev

Copy link
Copy Markdown
Contributor Author

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.

@keerthanakadiri

Copy link
Copy Markdown
Contributor

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>
@UgaTheDev

Copy link
Copy Markdown
Contributor Author

@keerthanakadiri CLA is signed, the cla/google check passes now.

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 np.nextafter over all 63490 non-NaN float16 values across 7 targets with no mismatches. 254 bfloat16 subnormals are still wrong because bfloat16 subnormals compare equal to zero in tf's comparison ops, which I noted in the earlier comment rather than claim it complete.

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>
@UgaTheDev

Copy link
Copy Markdown
Contributor Author

The openvino failure was mine. openvino's nextafter converts to float64, steps by a float64 ulp
(2^(floor(log2|x1|) - 52)), and casts the result back — so any step narrower than a float64 ulp
is lost in that cast, and subnormals flush to zero. My new assertions compare exactly (atol=0, rtol=0) precisely to catch a backend returning x1 unchanged, so they caught this.

That affects more than the float16 case I'd already skipped. On float32, openvino returns 0.0
for nextafter(0.0, 1.0) (want 1.4e-45) and 1.0 for nextafter(1.0, 2.0) (want 1.0000001).
CI only showed the first because it stops at the first failing assertion. I checked all six
combinations against openvino locally rather than guessing, and on openvino only the step away
from a float32 infinity is meaningful — a float16 infinity stays infinite because the float64
result overflows the cast back.

So openvino now asserts exactly that one case, which is the regression this PR fixes, and both
exact-ulp cases are skipped there with the shared reason in a comment. I replaced the old float16
skip comment too — it blamed 16-bit stepping specifically, but the limitation applies to every
dtype narrower than float64. Every other backend still runs all three assertions on float32 and
float16.

The earlier Run tests on CPU (tensorflow) red was a runner timeout (connect ETIMEDOUT to the
cluster API after the test phase); all nextafter cases passed in that job.

@keerthanakadiri keerthanakadiri added the stat:awaiting keras-eng Awaiting response from Keras engineer label Aug 27, 2026
@UgaTheDev

Copy link
Copy Markdown
Contributor Author

@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.

UgaTheDev added a commit to UgaTheDev/keras that referenced this pull request Aug 27, 2026
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 hertschuh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing this!

Comment thread keras/src/ops/numpy_test.py Outdated
Comment on lines +4633 to +4635
openvino = backend.backend() == "openvino"
dtypes = ["float32"] if openvino else ["float32", "float16"]
for dtype in dtypes:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of this:

@UgaTheDev UgaTheDev Sep 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread keras/src/ops/numpy_test.py Outdated
Comment on lines +4622 to +4632
# 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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 406d68e, replaced with the one-liner.

@hertschuh hertschuh added stat:awaiting response from contributor and removed stat:awaiting keras-eng Awaiting response from Keras engineer labels Sep 4, 2026
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>

@hertschuh hertschuh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix!

@google-ml-butler google-ml-butler Bot added kokoro:force-run ready to pull Ready to be merged into the codebase labels Sep 8, 2026
@google-ml-butler google-ml-butler Bot removed the ready to pull Ready to be merged into the codebase label Sep 8, 2026
@hertschuh
hertschuh merged commit 9501b95 into keras-team:master Sep 9, 2026
22 of 24 checks passed
goyaladitya05 added a commit to goyaladitya05/keras that referenced this pull request Sep 15, 2026
…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)
goyaladitya05 added a commit to goyaladitya05/keras that referenced this pull request Sep 15, 2026
goyaladitya05 pushed a commit to goyaladitya05/keras that referenced this pull request Sep 15, 2026
hertschuh pushed a commit that referenced this pull request Sep 16, 2026
* 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>
UgaTheDev added a commit to UgaTheDev/keras that referenced this pull request Sep 21, 2026
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 pushed a commit that referenced this pull request Oct 6, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

keras.ops.nextafter returns Inf instead of max finite float32 when moving from Inf toward -Inf

5 participants