New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Bugfixes for fractions() and decimals() #777
Conversation
ae1a38c
to
72339ed
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for looking into this!
I've gone through it with a pedant-toothed comb because numerics and there are a bunch of issues with the implementation, at least one of which I'm surprised the testing didn't caught so there might be some limitation to the testing that I'm not currently spotting that means it's testing less than it looks like.
The underlying diagnosis of the problem looks sound though.
src/hypothesis/strategies.py
Outdated
try: | ||
if min_value is not None: | ||
assert min_value <= min_value.quantize(factor) | ||
except (AssertionError, InvalidOperation): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm very unkeen on the use of an assertion for control flow. In theory you can turn assertions off (though it would be weird to do so when running Hypothesis I admit), and it's also just kinda ugly and non-intuitive.
src/hypothesis/strategies.py
Outdated
assert min_value <= min_value.quantize(factor) | ||
except (AssertionError, InvalidOperation): | ||
raise InvalidArgument( | ||
'min_value=%r is incompatible with places=%r and decimal ' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't really understand this error message, and I think I'd be even less likely to do so if I received it in the wild. Why is it incompatible?
with pytest.raises(InvalidArgument): | ||
ds.decimals(min_value=b, places=10).example() | ||
with pytest.raises(InvalidArgument): | ||
ds.decimals(max_value=b, places=10).example() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I find it slightly weird that a max_value
can be too large (or, conversely, that a min_value can be too small). I feel like this should be equivalent to the relevant bound being None
. I'm not wed to this position, just registering a vague dissatisfaction.
src/hypothesis/strategies.py
Outdated
@@ -214,7 +214,7 @@ def integers(min_value=None, max_value=None): | |||
max_int_value = None | |||
if max_value is not None: | |||
max_int_value = int(max_value) | |||
if max_int_value != max_value and max_value < 0: | |||
if max_int_value != max_value and max_value < 0: # pragma: no cover |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this no cover?
src/hypothesis/strategies.py
Outdated
try: | ||
min_value = Fraction(min_value) | ||
except (TypeError, ValueError): | ||
raise InvalidArgument('min_value=%r to Fraction' % (min_value,)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This isn't a very informative error message...
src/hypothesis/strategies.py
Outdated
return val | ||
|
||
if min_value is not None and max_value is not None and \ | ||
not (min_value <= clip_denominator(min_value) <= max_value): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you take my suggestion above then this check becomes simpler: After round min_value
up and max_value
down, you simply check again whether min_value <= max_value
.
docs/changes.rst
Outdated
|
||
* Fixed-point decimals would often fail with healthcheck errors due to changes | ||
in the underlying distribution of integers. Calculating the correct bound | ||
when none is given fixes :issue:`725`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: None
, not none
docs/changes.rst
Outdated
This is a bugfix release for the :func:`~hypothesis.strategies.fractions` | ||
and :func:`~hypothesis.strategies.decimals` strategies. | ||
|
||
* Fixed-point decimals would often fail with healthcheck errors due to changes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When written out like this health check should probably be two words.
from hypothesis.internal.compat import float_to_decimal | ||
|
||
|
||
@given(data()) | ||
def test_fuzz_fractions_bounds(data): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the addition of this (and the corresponding one for decimals)
def test_fuzz_fractions_bounds(data): | ||
denom = data.draw(none() | integers(1, 20)) | ||
fracs = none() | fractions(max_denominator=denom) \ | ||
| fractions('1/99', '1/2', denom) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It makes me sad that there's no equivalent to @example
for data
. I've still yet to figure out a good API for it.
c6d5b8d
to
95a1a35
Compare
|
||
return builds( | ||
Fraction, | ||
integers(min_value=min_num, max_value=max_num), | ||
just(denom) | ||
) | ||
|
||
return denominator_strategy.flatmap(dm_func) | ||
if max_denominator is None: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For clarity, move this block of code above dm_func
definition? All the lines down to 1037 could coneivably go above it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, again for clarity, an else might actually help, as:
if max_denominator is None:
...
else:
...
Otherwise, the fact that 1022 and 1036 are duplicates seems just a little odder.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We .flatmap(dm_func)
in this block, so it can't move down. I can add some comments if that would help - I found dealing with the simple case and then doing the other validation clarified things by removing many conditionals, but maybe I've just spent a few days in the area recently 😄
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Line 1043 won't work correctly if max_denominator
is None
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
frac.limit_denominator(None)
is a TypeError, at least under Python 3.
I could juggle it into a different order with much greater use of if ... is not None:
conditionals, but this was much easier to follow - the just(min_value)
case is repeated, but the rest falls into a clear sequence.
src/hypothesis/strategies.py
Outdated
|
||
if min_value is not None and min_value == max_value: | ||
return just(min_value) | ||
return integers(1, max_denominator).flatmap(dm_func)\ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do not understand how the changes in dm_func
and the corresponding use of limit_denominator
fix anything that was broken previously. It is clear what they do, but what do they fix?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There were two intertwined problems:
-
The value bounds might not be integers. If they're close together, integers() might be given an interval with no integers in it and therefore fail. To correct this, we convert our bounds to fractions up-front and do a little more algebra in dm_func.
-
Now that we're doing the extra algebra, dm_func can actually increase our denominator - potentially past the max_denominator bound. We therefore use the limit_denominator to clip it to the nearest fraction with at most that denominator, which is always within bounds by construction.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Of course the value bounds might not be integers. Indeed, the call to integers() might be given an interval with no integers in it. In that case, dm_func(<some denom>)
will return a strategy that doesn't produce any values. How is that a bug?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a bug because fractions()
was given valid bounds, and the strategy is meant to produce values.
@@ -205,6 +205,9 @@ def integers(min_value=None, max_value=None): | |||
from hypothesis.searchstrategy.numbers import IntegersFromStrategy, \ | |||
BoundedIntStrategy, WideRangeIntStrategy | |||
|
|||
# Why not a simpler implmentation? eg: | |||
# `min_int_value = None if min_value is None else ceil(min_value)` | |||
# Large values then misbehave under Python 2, so we're inelegant for now |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does misbehave mean here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cause my fuzz-tests to fail, really. I've poked at it a bit and decided that it's working and not inelegant enough to make me want to trace Python2 int bounding issues.
src/hypothesis/strategies.py
Outdated
'=%r' % (min_value, max_denominator)) | ||
if max_value is not None and max_value.denominator > max_denominator: | ||
raise InvalidArgument('max_value=%r incompatible with max_denominator' | ||
'=%r' % (max_value, max_denominator)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I kinda feel like this is a breaking change unfortunately...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's true that this would not previous raise an exception, but nor has it ever worked correctly - if you tried, the denominator bound would be respected but the value bounds would not!
There are some cases where with a value-bound-denominator greater than the max_denominator, no examples are possible: eg fractions('1/3', '2/3', 1)
has no valid output and nor has fractions(.5, .5, 1)
.
I see three choices:
- Allow some examples to be outside the specified bounds, or allow bounds with no possible examples.
- Validate the value-bounds with respect to the precision bound. (I've done this because I like it - the way the various intervals all fall into place is lovely). Note that value bounds may be unrepresentable with fractions of max_denominator; it all still works perfectly.
- Try to validate whether there are any possible outputs in cases where a bound has too high a denominator. If
max_value - min_value >= Fraction(1, max_denominator)
, this is trivially true. If not, I haven't yet been able to get possible example denominator values in less than linear time of the greatest denominator (by checking each). The trivial case of equal value bounds with an illegal denominator is obvious but not much use.
The whole point of this pull is that I don't like option one, and three is similar but less consistent!
src/hypothesis/strategies.py
Outdated
@@ -982,32 +987,61 @@ def fractions(min_value=None, max_value=None, max_denominator=None): | |||
|
|||
If max_denominator is not None then the absolute value of the denominator | |||
of any generated values is no greater than max_denominator. Note that | |||
max_denominator must be at least 1. | |||
max_denominator must be None or a positive integer, and the denominator |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you're changing the semantics radically. In the previous version of this method, the form of the two bounds was completely orthogonal to the choice of denominator. They constituted bounds, upper and lower limits, and carried no further information. I think that was a good choice.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See my response to @DRMacIver above - it was broken before and alternatives are worse 😞
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't believe that you have demonstrated that the current version is broken in any fundamental way. It is probably easy to return an error in the case where the arguments specified can not yield any values, and that would probably be a good idea.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Try fractions(Fraction(1, 4), Fraction(1, 2), 4).example()
on master - you'll see some assertion errors from within integers()
even though all the bounds are compatible.
Having fixed that, I actually can't determine in the general case whether there are any legal values when the value bounds are at higher precision than allowed for examples. I'd be very happy to be proved wrong here!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I see is a bug in the integers()
strategy that needs to be fixed (that has been latent for a while).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we're probably spending a lot of time worrying about a niche case that doesn't matter all that much and it's OK to handle a bit suboptimally.
Here's a proposed simple solution:
- We stop worrying about catching conflicts between bounds and
max_denominator
up front and allow unsatisfiable tests to fail at runtime. - We do this by going back to the proposed clipping behaviour - if when rounding we get something outside the bounds, we snap to the nearest bound.
- If the bound in question has too large a denominator, we filter out the example with assume and allow example generation to try again.
I think that handles all the happy paths well and degrades gracefully in the presence of hard or impossible to satisfy constraints.
How does that sound?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm still struggling to understand precisely what problem it is the purpose of these changes to fix.
I'm unconvinced that the change to integers()
strategy is not a first step in the wrong direction.
I'm going to ask my former professor, retired, about the problem of deciding whether there exists a rational number of a designated denominator between two rational bounds. He's a an algebraist, he might be interested. But that's orthogonal to the main problem, AFAIAC.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
deciding whether there exists a rational number of a designated denominator between two rational bounds
Note that the problem isn't a single designated denominator - that's easy. You just multiply both bounds by the denominator, round the bottom up to an integer and the top down to an integer, and if bounds are still in the right order then you've got a rational of that denominator between them (anything in that integer range divided by the denominator) and if they're not then none can exist..
The problem scenario is finding one of bounded denominator without checking every value <= n
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. I gave him a full description of the problem :) But I'm not optimistic that there is any insight that can be offered beyond the obvious that iff
- the max denominator is less than the denominator of each endpoint, AND
- the range between the endpoints is less than 1/the max denominator
then it may be computationally expensive to decide if there is an example.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does that [simple solution] sound?
In any case that mine doesn't cover (see below), this will avoid the filter in literally less than one in several million cases. Will push after rebasing.
I'm still struggling to understand precisely what problem it is the purpose of these changes to fix.
If the min_value or max_value were not integers (and we didn't enforce this, so it's reasonable to assume that fractions are supported), the generated values were not properly bounded. In some cases, this would also trigger an assertion in integers()
which I have replaced with a validation call. Plus consequential changes to maintain our max_denominator invariant.
The problem scenario is finding one of bounded denominator without checking every value <= n.
I think it's reasonable to bail out with an explicit error if there is no such value with denominator between n and n-1000. Does that fit with our compatibility commitment? I think this check is exhaustive for almost all user inputs, and not too expensive at very high precision.
Hypothesis 3.12 made very large integers more common, which in turn caused errors due to precision limits. Setting a default bounding value from the precison and checking user bounds should fix this for good.
95a1a35
to
78daba2
Compare
I think it would be helpful to have a super-comment for this PR, stating what it accomplishes. |
(moved to top) |
I'm sorry I was unspecific. I think a super-comment should go at the top, where it is easy to find. |
I think everyone (me especially included) is getting confused by this pull request and getting frustrated as a result. I know that I've lost the plot ab it about what it does and what it fixes. I think part of the problem here is that it's fixing multiple different issues, some dependent and some orthogonal. In particular we've got:
Even though it's a relatively small pull request by line count there are enough moving parts that I think it would benefit from those issues being split up into their own separate pull requests - maybe bundling 3 and 4 into one, but certainly splitting out 1 and 2. On top of that, the issues it's dealing with are super-fiddly. I think it would probably help (both here and in general when the issue is not super obvious) if bug fix pull requests came with a short explanation of what goes wrong, why it's wrong (i.e. underlying cause) and what the new behaviour is. PS. Yes I'm aware there's a certain amount of hypocrisy in asking for smaller pull requests given my currently open huge one. Sorry. |
Sounds good - I'll cherry-pick this into separate pulls to deal with integers, decimals, and fractions. |
fractions()
- casting bounds to fraction and upgradingdm_func
resolves the issue.fractions()
which only worked because the other bounds didn't.