Repository: HypothesisWorks/hypothesis

Issue:
Float strategy generates -0.0 for max_value=0.0 when exclude_max=True
As a user, I'd expect values drawn from a strategy with `exclude_max=True` to have the invariant `value < max_value`. However, I discovered that a floats strategy with max_value set to 0.0 can generate  -0.0 even if `exclude_max=True`. This is unexpected since 0.0 == -0.0.

Example program (hypothesis 4.43.6): 
```python3
from hypothesis import given
from hypothesis.strategies import floats


max_val = 0.0
less_than_zero = floats(max_value=max_val, exclude_max=True, allow_nan=False)

@given(less_than_zero)
def less_than_max(val):
    assert val < max_val


if __name__ == "__main__":
    less_than_max()
```

Example output (excluding traceback):
```
Falsifying example: less_than_max(val=-0.0)
```

The issue seems to stem from changes to `floats.py` in 36d641d2f4f09ef78d27faa7e0163b89526e2d60. The version of prior to this commit matches the referenced [Stack Overflow solution](https://stackoverflow.com/a/10426033/8777152) and would not cause this particular issue.

If I understood it correctly, PR #1860 solved the issue of `floats(min_value=0.0, max_value=-0.0)` and excluding endpoints at infinity ( #1859).
Perhaps this was an unintended side-effect?

For now, I can avoid this with, for instance:
```python3
less_than_zero = less_than_zero.filter(lambda x: x < max_val)
```
but this should not really be necessary. Would it be possible to consolidate solutions to both  issues?


Discussion:
> As a user, I'd expect values drawn from a strategy with `exclude_max=True` to have the invariant `value < max_value`. However, I discovered that a floats strategy with max_value set to `0.0` can generate `-0.0` even if `exclude_max=True`. This is unexpected since 0.0 == -0.0.

FWIW this was intentional as it exposes the underlying - and I agree unintuitive - behaviour of IEEE754 floats regarding signed zeros.  

I can see that it might be a better compromise to ensure that excluding either zero excludes both though!
I think regardless of what we decide as the API our documentation of the floats strategy needs to clearly signal it - right now it's pretty non-obvious from the documentation that this is the behaviour!
> FWIW this was intentional as it exposes the underlying - and I agree unintuitive - behaviour of IEEE754 floats regarding signed zeros.

Was there any reason in particular to distinguish between signed/unsigned zeros in next_up?

In any case, should this still be the desired behaviour, then I definitely agree that the docs should be made clearer! But I do think that the API should exclude both zeros in exclude_min/max.

Resolving patch:
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
@@ -435,8 +435,10 @@ def floats(
     # type: (...) -> SearchStrategy[float]
     """Returns a strategy which generates floats.
 
-    - If min_value is not None, all values will be >= min_value.
-    - If max_value is not None, all values will be <= max_value.
+    - If min_value is not None, all values will be ``>= min_value``
+      (or ``> min_value`` if ``exclude_min``).
+    - If max_value is not None, all values will be ``<= max_value``
+      (or ``< max_value`` if ``exclude_max``).
     - If min_value or max_value is not None, it is an error to enable
       allow_nan.
     - If both min_value and max_value are not None, it is an error to enable
@@ -454,6 +456,7 @@ def floats(
 
     The exclude_min and exclude_max argument can be used to generate numbers
     from open or half-open intervals, by excluding the respective endpoints.
+    Excluding either signed zero will also exclude the other.
     Attempting to exclude an endpoint which is None will raise an error;
     use ``allow_infinity=False`` to generate finite floats.  You can however
     use e.g. ``min_value=float("-inf"), exclude_min=True`` to exclude only
@@ -520,12 +523,20 @@ def floats(
         exclude_min or (min_arg is not None and min_value < min_arg)
     ):
         min_value = next_up(min_value, width)
-        assert min_value > min_arg or min_value == min_arg == 0  # type: ignore
+        if min_value == min_arg:
+            assert min_value == min_arg == 0
+            assert is_negative(min_arg) and not is_negative(min_value)
+            min_value = next_up(min_value, width)
+        assert min_value > min_arg  # type: ignore
     if max_value is not None and (
         exclude_max or (max_arg is not None and max_value > max_arg)
     ):
         max_value = next_down(max_value, width)
-        assert max_value < max_arg or max_value == max_arg == 0  # type: ignore
+        if max_value == max_arg:
+            assert max_value == max_arg == 0
+            assert is_negative(max_value) and not is_negative(max_arg)
+            max_value = next_down(max_value, width)
+        assert max_value < max_arg  # type: ignore
 
     if min_value == float(u"-inf"):
         min_value = None


Test patch:
diff --git a/hypothesis-python/tests/cover/test_float_nastiness.py b/hypothesis-python/tests/cover/test_float_nastiness.py
--- a/hypothesis-python/tests/cover/test_float_nastiness.py
+++ b/hypothesis-python/tests/cover/test_float_nastiness.py
@@ -294,16 +294,23 @@ def test_exclude_entire_interval(lo, hi, bound):
         st.floats(bound, bound, exclude_min=lo, exclude_max=hi).validate()
 
 
-def test_exclude_zero_interval():
+def test_zero_intervals_are_OK():
+    st.floats(0.0, 0.0).validate()
     st.floats(-0.0, 0.0).validate()
-    st.floats(-0.0, 0.0, exclude_min=True).validate()
-    st.floats(-0.0, 0.0, exclude_max=True).validate()
+    st.floats(-0.0, -0.0).validate()
 
 
 @checks_deprecated_behaviour
 def test_inverse_zero_interval_is_deprecated():
     st.floats(0.0, -0.0).validate()
-    st.floats(-0.0, 0.0, exclude_min=True, exclude_max=True).validate()
+
+
+@pytest.mark.parametrize("lo", [0.0, -0.0])
+@pytest.mark.parametrize("hi", [0.0, -0.0])
+@pytest.mark.parametrize("exmin,exmax", [(True, False), (False, True), (True, True)])
+def test_cannot_exclude_endpoint_with_zero_interval(lo, hi, exmin, exmax):
+    with pytest.raises(InvalidArgument):
+        st.floats(lo, hi, exclude_min=exmin, exclude_max=exmax).validate()
 
 
 WIDTHS = (64, 32)