Skip to content

Commit

Permalink
Report signaling and -nan
Browse files Browse the repository at this point in the history
This is a pretty niche problem to have, but when you have it it's really nice to know.
  • Loading branch information
Zac-HD committed Nov 21, 2021
1 parent 13e9404 commit e060521
Show file tree
Hide file tree
Showing 5 changed files with 66 additions and 3 deletions.
10 changes: 10 additions & 0 deletions hypothesis-python/RELEASE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
RELEASE_TYPE: minor

Did you know that of the 2\ :superscript:`64` possible floating-point numbers,
2\ :superscript:`53` of them are ``nan`` - and Python prints them all the same way?

While nans *usually* have all zeros in the sign bit and mantissa, this
`isn't always true <https://wingolog.org/archives/2011/05/18/value-representation-in-javascript-implementations>`__,
and :wikipedia:`'signaling' nans might trap or error <https://en.wikipedia.org/wiki/NaN#Signaling_NaN>`.
To help distinguish such errors in e.g. CI logs, Hypothesis now prints ``-nan`` for
negative nans, and adds a comment like ``# Saw 3 signaling NaNs`` if applicable.
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ def integers(
return IntegersStrategy(min_value, max_value)


SIGNALING_NAN = int_to_float(0x7FF8_0000_0000_0001) # nonzero mantissa
assert math.isnan(SIGNALING_NAN) and math.copysign(1, SIGNALING_NAN) == 1

NASTY_FLOATS = sorted(
[
0.0,
Expand All @@ -170,7 +173,8 @@ def integers(
]
+ [2.0 ** -n for n in (24, 14, 149, 126)] # minimum (sub)normals for float16,32
+ [float_info.min / n for n in (2, 10, 1000, 100_000)] # subnormal in float64
+ [math.inf, math.nan] * 5,
+ [math.inf, math.nan] * 5
+ [SIGNALING_NAN],
key=flt.float_to_lex,
)
NASTY_FLOATS = list(map(float, NASTY_FLOATS))
Expand Down
22 changes: 21 additions & 1 deletion hypothesis-python/src/hypothesis/vendor/pretty.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,12 @@ def _repr_pretty_(self, p, cycle):
import datetime
import platform
import re
import struct
import types
from collections import deque
from contextlib import contextmanager
from io import StringIO
from math import copysign, isnan

__all__ = [
"pretty",
Expand Down Expand Up @@ -161,6 +163,8 @@ def __init__(
self.group_queue = GroupQueue(root_group)
self.indentation = 0

self.snans = 0

def _break_outer_groups(self):
while self.max_width < self.output_width + self.buffer_width:
group = self.group_queue.deq()
Expand Down Expand Up @@ -266,6 +270,12 @@ def end_group(self, dedent=0, close=""):

def flush(self):
"""Flush data that is left in the buffer."""
if self.snans:
# Reset self.snans *before* calling breakable(), which might flush()
snans = self.snans
self.snans = 0
self.breakable(" ")
self.text(f"# Saw {snans} signaling NaN" + "s" * (snans > 1))
for data in self.buffer:
self.output_width += data.output(self.output, self.output_width)
self.buffer.clear()
Expand Down Expand Up @@ -735,10 +745,20 @@ def _exception_pprint(obj, p, cycle):
p.end_group(step, ")")


def _repr_float_counting_nans(obj, p, cycle):
if isnan(obj) and hasattr(p, "snans"):
if struct.pack("!d", abs(obj)) != struct.pack("!d", float("nan")):
p.snans += 1
if copysign(1.0, obj) == -1.0:
p.text("-nan")
return
p.text(repr(obj))


#: printers for builtin types
_type_pprinters = {
int: _repr_pprint,
float: _repr_pprint,
float: _repr_float_counting_nans,
str: _repr_pprint,
tuple: _seq_pprinter_factory("(", ")", tuple),
list: _seq_pprinter_factory("[", "]", list),
Expand Down
15 changes: 15 additions & 0 deletions hypothesis-python/tests/cover/test_pretty.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
import pytest

from hypothesis.internal.compat import PYPY
from hypothesis.strategies._internal.numbers import SIGNALING_NAN
from hypothesis.vendor import pretty


Expand Down Expand Up @@ -622,3 +623,17 @@ def test_empty_printer():

def test_breakable_at_group_boundary():
assert "\n" in pretty.pretty([[], "000000"], max_width=5)


@pytest.mark.parametrize(
"obj, rep",
[
(float("nan"), "nan"),
(-float("nan"), "-nan"),
(SIGNALING_NAN, "nan # Saw 1 signaling NaN"),
(-SIGNALING_NAN, "-nan # Saw 1 signaling NaN"),
((SIGNALING_NAN, SIGNALING_NAN), "(nan, nan) # Saw 2 signaling NaNs"),
],
)
def test_nan_reprs(obj, rep):
assert pretty.pretty(obj) == rep
16 changes: 15 additions & 1 deletion hypothesis-python/tests/nocover/test_floating.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@
import pytest

from hypothesis import HealthCheck, assume, given, settings
from hypothesis.internal.floats import next_down
from hypothesis.internal.floats import float_to_int, next_down
from hypothesis.strategies import data, floats, lists

from tests.common.debug import find_any
from tests.common.utils import fails

TRY_HARDER = settings(
Expand Down Expand Up @@ -161,3 +162,16 @@ def test_floats_are_in_range(x, y, data):

t = data.draw(floats(x, y))
assert x <= t <= y


@pytest.mark.parametrize("neg", [False, True])
@pytest.mark.parametrize("snan", [False, True])
def test_can_find_negative_and_signaling_nans(neg, snan):
find_any(
floats().filter(math.isnan),
lambda x: (
snan is (float_to_int(abs(x)) != float_to_int(float("nan")))
and neg is (math.copysign(1, x) == -1)
),
settings=TRY_HARDER,
)

0 comments on commit e060521

Please sign in to comment.