Repository: HypothesisWorks/hypothesis

Issue:
integers(x, x) can generate (x-1) if x is an int64
Hi,

Just stumbled upon this by accident. Using hypothesis 3.68.0 and numpy 1.15.2:

```python
from hypothesis import given
from hypothesis.strategies import data, integers
from hypothesis.extra.numpy import from_dtype, integer_dtypes

@given(data=data(), dtype=integer_dtypes())
def test_numpy_integers(data, dtype):
    x = data.draw(from_dtype(dtype))
    assert data.draw(integers(x, x)) == x
```

For example, with the int64 type we can fail with `x = 9007199254740993`, with `integers()` generating `x - 1`. I've not seen examples of anything other than `x - 1` being generated.


Discussion:
I had a quick look into this, and was surprised to find that `floor(x) != x` for some values of `x` that are `numpy.int64`.

Since we call `floor` and `ceil` on the arguments to `integers()`, this causes strange things to happen.

I haven't had a chance to figure out what's actually going on here.
The [return type of `numpy.ceil` is `float64`](https://docs.scipy.org/doc/numpy-1.15.1/reference/generated/numpy.ceil.html), just like `math.ceil` in Python 2.  Apparently there is also an intermediate cast to `float` in the `__ceil__` method of integer dtype values - for example `x = 9007199254740993 = 2 ** 53 + 1`, and `np.ceil(x) == 9007199254740992.0`.  This is actually also the Python2 behaviour of `math.ceil` (which we work around in Hypothesis), so it's certainly not unprecedented!

I've added a comment to the upstream issue, but for us the best option is probably to use the Python 2 implementation on all versions for now.

Resolving patch:
diff --git a/hypothesis-python/src/hypothesis/internal/compat.py b/hypothesis-python/src/hypothesis/internal/compat.py
--- a/hypothesis-python/src/hypothesis/internal/compat.py
+++ b/hypothesis-python/src/hypothesis/internal/compat.py
@@ -81,6 +81,7 @@ def int_to_text(i):
     hrange = range
     ARG_NAME_ATTRIBUTE = 'arg'
     integer_types = (int,)
+    _long_integer_type = int
     hunichr = chr
 
     def unicode_safe_repr(x):
@@ -228,6 +229,7 @@ def shimrange():
 
     ARG_NAME_ATTRIBUTE = 'id'
     integer_types = (int, long)
+    _long_integer_type = long
     hunichr = unichr
 
     def escape_unicode_characters(s):
@@ -516,22 +518,24 @@ def implements_iterator(it):
     FileExistsError = None
 
 
-if PY2:
-    # Under Python 2, math.floor and math.ceil return floats, which cannot
-    # represent large integers - eg `float(2**53) == float(2**53 + 1)`.
-    # We therefore implement them entirely in (long) integer operations.
-    def floor(x):
-        if int(x) != x and x < 0:
-            return int(x) - 1
-        return int(x)
-
-    def ceil(x):
-        if int(x) != x and x > 0:
-            return int(x) + 1
-        return int(x)
-else:
-    floor = math.floor
-    ceil = math.ceil
+# Under Python 2, math.floor and math.ceil return floats, which cannot
+# represent large integers - eg `float(2**53) == float(2**53 + 1)`.
+# We therefore implement them entirely in (long) integer operations.
+# We use the same trick on Python 3, because Numpy values and other
+# custom __floor__ or __ceil__ methods may convert via floats.
+# See issue #1667, Numpy issue 9068.
+def floor(x):
+    y = _long_integer_type(x)
+    if y != x and x < 0:
+        return y - 1
+    return y
+
+
+def ceil(x):
+    y = _long_integer_type(x)
+    if y != x and x > 0:
+        return y + 1
+    return y
 
 
 try:
diff --git a/hypothesis-python/src/hypothesis/strategies.py b/hypothesis-python/src/hypothesis/strategies.py
--- a/hypothesis-python/src/hypothesis/strategies.py
+++ b/hypothesis-python/src/hypothesis/strategies.py
@@ -335,15 +335,15 @@ def integers(min_value=None, max_value=None):
 
     if min_value != min_int_value:
         note_deprecation(
-            'min_value=%r cannot be exactly represented as an integer, which '
-            'will be an error in a future version.'
-            % (min_value)
+            'min_value=%r of type %r cannot be exactly represented as an '
+            'integer, which will be an error in a future version.  '
+            'Use %r instead.' % (min_value, type(min_value), min_int_value)
         )
     if max_value != max_int_value:
         note_deprecation(
-            'max_value=%r cannot be exactly represented as an integer, which '
-            'will be an error in a future version.'
-            % (max_value)
+            'max_value=%r of type %r cannot be exactly represented as an '
+            'integer, which will be an error in a future version.  '
+            'Use %r instead.' % (max_value, type(max_value), max_int_value)
         )
 
     if min_int_value is not None and max_int_value is not None and \


Test patch:
diff --git a/hypothesis-python/tests/numpy/test_narrow_floats.py b/hypothesis-python/tests/numpy/test_narrow_floats.py
--- a/hypothesis-python/tests/numpy/test_narrow_floats.py
+++ b/hypothesis-python/tests/numpy/test_narrow_floats.py
@@ -20,7 +20,8 @@
 import numpy as np
 
 from hypothesis import given
-from hypothesis.strategies import floats
+from hypothesis.strategies import data, floats, integers
+from hypothesis.extra.numpy import from_dtype, integer_dtypes
 
 
 @given(floats(width=32))
@@ -39,3 +40,11 @@ def test_float16_exactly_representable(x):
         assert np.isnan(clipped)
     else:
         assert x == float(clipped)
+
+
+@given(data=data(), dtype=integer_dtypes())
+def test_floor_ceil_lossless(data, dtype):
+    # Regression test for issue #1667; ceil converting numpy integers
+    # to float and back to int with loss of exact value.
+    x = data.draw(from_dtype(dtype))
+    assert data.draw(integers(x, x)) == x