Skip to content

Commit e789d4b

Browse files
authored
Merge pull request #4279 from jobh/nan-shrink
Shrink NaN to canonical form
2 parents 894fe9d + 14a5656 commit e789d4b

File tree

6 files changed

+46
-30
lines changed

6 files changed

+46
-30
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
RELEASE_TYPE: patch
2+
3+
Improve shrinking of non-standard NaN float values (:issue:`4277`).

hypothesis-python/src/hypothesis/internal/conjecture/choice.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,7 @@ def choice_to_index(choice: ChoiceT, kwargs: ChoiceKwargsT) -> int:
417417
to_order=intervals.index_from_char_in_shrink_order,
418418
)
419419
elif isinstance(choice, float):
420-
sign = int(sign_aware_lte(choice, -0.0))
420+
sign = int(math.copysign(1.0, choice) < 0)
421421
return (sign << 64) | float_to_lex(abs(choice))
422422
else:
423423
raise NotImplementedError

hypothesis-python/src/hypothesis/internal/conjecture/shrinking/common.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def __init__(
3434
self.name = name
3535

3636
self.__predicate = predicate
37-
self.__seen = set()
37+
self.__seen = {self.make_canonical(self.current)}
3838
self.debugging_enabled = debug
3939

4040
@property
@@ -107,39 +107,46 @@ def run(self):
107107
self.run_step()
108108
self.debug("COMPLETE")
109109

110-
def incorporate(self, value):
110+
def consider(self, value):
111111
"""Try using ``value`` as a possible candidate improvement.
112112
113-
Return True if it works.
113+
Return True if self.current is canonically equal to value after the call, either because
114+
the value was incorporated as an improvement or because it had that value already.
114115
"""
115116
value = self.make_immutable(value)
117+
self.debug(f"considering {value!r}")
118+
canonical = self.make_canonical(value)
119+
if canonical == self.make_canonical(self.current):
120+
return True
121+
if canonical in self.__seen:
122+
return False
123+
self.__seen.add(canonical)
116124
self.check_invariants(value)
117125
if not self.left_is_better(value, self.current):
118-
if value != self.current and (value == value):
119-
self.debug(f"Rejected {value!r} as worse than {self.current=}")
120-
return False
121-
if value in self.__seen:
126+
self.debug(f"Rejected {value!r} as no better than {self.current=}")
122127
return False
123-
self.__seen.add(value)
124128
if self.__predicate(value):
125129
self.debug(f"shrinking to {value!r}")
126130
self.changes += 1
127131
self.current = value
128132
return True
129-
return False
133+
else:
134+
self.debug(f"Rejected {value!r} not satisfying predicate")
135+
return False
130136

131-
def consider(self, value):
132-
"""Returns True if make_immutable(value) == self.current after calling
133-
self.incorporate(value)."""
134-
self.debug(f"considering {value}")
135-
value = self.make_immutable(value)
136-
if value == self.current:
137-
return True
138-
return self.incorporate(value)
137+
def make_canonical(self, value):
138+
"""Convert immutable value into a canonical and hashable, but not necessarily equal,
139+
representation of itself.
140+
141+
This representation is used only for tracking already-seen values, not passed to the
142+
shrinker.
143+
144+
Defaults to just returning the (immutable) input value.
145+
"""
146+
return value
139147

140148
def make_immutable(self, value):
141-
"""Convert value into an immutable (and hashable) representation of
142-
itself.
149+
"""Convert value into an immutable representation of itself.
143150
144151
It is these immutable versions that the shrinker will work on.
145152

hypothesis-python/src/hypothesis/internal/conjecture/shrinking/floats.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,23 @@
1414
from hypothesis.internal.conjecture.floats import float_to_lex
1515
from hypothesis.internal.conjecture.shrinking.common import Shrinker
1616
from hypothesis.internal.conjecture.shrinking.integer import Integer
17-
from hypothesis.internal.floats import MAX_PRECISE_INTEGER
17+
from hypothesis.internal.floats import MAX_PRECISE_INTEGER, float_to_int
1818

1919

2020
class Float(Shrinker):
2121
def setup(self):
22-
self.NAN = math.nan
2322
self.debugging_enabled = True
2423

25-
def make_immutable(self, f):
26-
f = float(f)
24+
def make_canonical(self, f):
2725
if math.isnan(f):
28-
# Always use the same NAN so it works properly in self.seen
29-
f = self.NAN
26+
# Distinguish different NaN bit patterns, while making each equal to itself.
27+
# Wrap in tuple to avoid potential collision with (huge) finite floats.
28+
return ("nan", float_to_int(f))
3029
return f
3130

3231
def check_invariants(self, value):
33-
# We only handle positive floats because we encode the sign separately
34-
# anyway.
32+
# We only handle positive floats (including NaN) because we encode the sign
33+
# separately anyway.
3534
assert not (value < 0)
3635

3736
def left_is_better(self, left, right):

hypothesis-python/tests/conjecture/test_float_encoding.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

11+
import math
1112
import sys
1213

1314
import pytest
@@ -16,7 +17,7 @@
1617
from hypothesis.internal.compat import ceil, extract_bits, floor
1718
from hypothesis.internal.conjecture import floats as flt
1819
from hypothesis.internal.conjecture.engine import ConjectureRunner
19-
from hypothesis.internal.floats import float_to_int
20+
from hypothesis.internal.floats import SIGNALING_NAN, float_to_int
2021

2122
EXPONENTS = list(range(flt.MAX_EXPONENT + 1))
2223
assert len(EXPONENTS) == 2**11
@@ -200,3 +201,9 @@ def test_reject_out_of_bounds_floats_while_shrinking():
200201
kwargs = {"min_value": 103.0}
201202
g = minimal_from(103.1, lambda x: x >= 100, kwargs=kwargs)
202203
assert g == 103.0
204+
205+
206+
@pytest.mark.parametrize("nan", [-math.nan, SIGNALING_NAN, -SIGNALING_NAN])
207+
def test_shrinks_to_canonical_nan(nan):
208+
shrunk = minimal_from(nan, math.isnan)
209+
assert float_to_int(shrunk) == float_to_int(math.nan)

hypothesis-python/tests/cover/test_shrink_budgeting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
[
2121
(Integer, 2**16),
2222
(Integer, int(sys.float_info.max)),
23-
(Ordering, [[100] * 10]),
23+
(Ordering, [(100,) * 10]),
2424
(Ordering, [i * 100 for i in (range(5))]),
2525
(Ordering, [i * 100 for i in reversed(range(5))]),
2626
],

0 commit comments

Comments
 (0)