-
Notifications
You must be signed in to change notification settings - Fork 30
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
Same input repeatedly tested during shrinking #224
Comments
I think I better understand this behavior. From the failed value
Then these values are tested in order (i.e. from left to right). The test first passes for |
I think I better understand the semantics of the shrink tree now. The root represents a value that causes the test to fail. The next value to test is its first child. If some child fails, then we recurse with the subtree rooted at that child. Whenever the value of a child causes the test to pass, then the value of the next child is tested. The shrinking process stops when the values of all children of some root value pass the test (which could be vacuously true when the root has no children). And it isn't just let n = 4
let shrink = Shrink.towards 0
let tree = Tree.unfold id shrink n
tree |> Tree.map (sprintf "%A") |> Tree.render
Suppose the test passes for
which repeats both The "shrink tree" corresponding to the values considered in a binary search (which doesn't include any repeats) is
|
For comparison, consider a recent improvement to Haskell Hedgehog, especially hedgehogqa/haskell-hedgehog#406 (comment). In the example considered in that comment, there is an interval with values
and these values are in order from "smallest" to "largest". The shrink tree starting at
Unlike the above shrink tree created by the F# code, this shrink tree "remembers" that |
When I created this issue, I knew that it was similar to the behavior improved by the Haskell PR, but I thought that maybe this issue was a slightly different issue that would be easier to fix. Now I am thinking that it is the same issue and I merely found a different way to demonstrate the inefficiency. |
That's right. I believe this behavior exists also in Haskell's I haven't verified this yet, but the way I'd verify it is by runing GHCi and F# Interactive sessions side-by-side and comparing the results, having the GHCi one as reference (most of the times). However, perhaps it'll be easier to quasiquote F# tests in Haskell and compare the two programmatically instead of relying on comparing them manually. |
But how can it be the same issue? The fix in hedgehogqa/haskell-hedgehog#406 was only in the |
Off the top of my head, I can't really tell about this particular one. I'd have to look closer. Your analysis is very helpful though. |
You mentioned in #224 (comment):
Do you consider that (rendered) tree correct? ⬆️ I'm pretty sure we do the exact same in Haskell if you execute this in GHCi (typing this on the top off my head) import Hedgehog.Internal.Tree as Tree
import Hedgehog.Internal.Shrink as Shrink
tree = Tree.unfold (Shrink.towards 0) 4
Tree.render (fmap show tree) Shouldn't the output be instead
If yes, then fixing it at the tree/shrink level wouldn't require modifying each and every gen (as in hedgehogqa/haskell-hedgehog#406). |
I am not certain that I know what you mean by "correct". One meaning is "the same as in Haskell Hedgehog". I expect you are right that the behavior of this code is the same for both the F# and Haskell versions of Hedgehog. (I would test it, but I don't know how to setup a Haskell environment. It is on my TODO list though.) Another possible meaning is "as I expect". I expect that this tree corresponds to the values tested by binary search. In this sense, the tree is not correct.
I think it should be that smaller tree instead.
Indeed. In fact, after achieving the behavior I expect for |
@ocharles, @HuwCampbell, it'd be nice to have your thoughts around this. ━ FYI, this is a continuation (and a generalization) of the discussion in hedgehogqa/haskell-hedgehog#406 (and hedgehogqa/haskell-hedgehog#118). The idea here is that maybe we can work in the
the output produced by import Hedgehog.Internal.Tree as Tree
import Hedgehog.Internal.Shrink as Shrink
tree = Tree.unfold (Shrink.towards 0) 4
Tree.render (fmap show tree) would be
This was originally brought up by @TysonMN here in #224 (comment). |
I have not yet tried to achieve the behavior for fsharp-hedgehog/src/Hedgehog/Gen.fs Lines 258 to 259 in 333d331
...which uses an integral to select the index into an array of generators. The only difference is that the distribution of indices at the root of the shrink tree should be weighted.
|
In general we try to shrink smallest values first instead, as in complex structures this quickly removes irrelevant terms. So I don't think this would work very well
Each sub-tree essentially repeats the same shrink strategy. This isn't too bad, as the number of duplicates is usually only the 0 term; and it means we'll always reach something small even after a few binds. It might be possible for the trees to build something more like a binary search with smallest values tested first, but I can't off the top of my head I can't see how it would interact with bind and apply. So the "ideal" tree may be more like this:
But without an implementation and testing it with floating values and through a bind I can't say for sure. |
Also, I have no idea how it would work for something like shrinking a list. |
I am not completely sure I understand what you mean. I will rephrase into my own words what I think you mean.
Is that an accurate rephrasing of what you mean?
Might? I am not sure that we are on the same page. I have contributed PR #239 to fix this issue. My claim is that the code in that PR produces shrink trees that precisely correspond to the behavior of binary search. My impression is that you would prefer to deviate from the exact behavior of binary search by testing the smallest value first. I want that behavior as well. However, I think the proper way to achieve that optimized behavior is to first create shrink trees that exactly match the behavior of binary search and then optimize them to test the smallest value first. I think the applicative and monadic behaviors of I think the applicative and monadic behaviors of |
Sorry I was a bit sloppy using might there. Obviously for integers it is absolutely possible to build the tree I wrote above (although, as I'm sure you've noticed, not with the Sorry I have never used f# at all nor looked at the implementation of fsharp-hedgehog; I can't comment on your PR. If you provide some examples over on that PR I would have more to say.
This isn't quite what I meant. When shrinking large product types, more often than not, only some of the elements are the cause of a failure. Shrinking to the smallest terms first means we quickly exclude irrelevant terms (ones which don't affect failure or success) from subsequent shrinking operations, and speeds up shrinking in general. |
I intend to optimize the shrink tree to start by testing the smallest value first. I think the proper way to obtain a shrink tree that both
is to first do exactly what binary search would do and then modify that behavior to test the smallest value first. I am using integers as an example. The code I wrote works for any integral type. The only non-integral type supported by fsharp-hedgehog seems to be
Indeed. The way I see it,
I don't know what you mean. Hedgehog sees the test as a black box. The only way it can exclude some term as irrelevant is by testing every possible input and in particular varying the term in question and observing that the outcome of the test is unchanged. However, that is not what Hedgehog does. Therefore, Hedgehog cannot exclude some term as irrelevant. |
Sorry. I just mean this in (imagining haskell had records): underTest :: { a: Int, b: Double, c: Int } -> Property
underTest = property $
record <- forAll genRecord
assert $ record.c < 15 Here the values of |
Yep, that is the current behavior. And I want that behavior as well. I just think the proper way to obtain a shrink tree that both
is to first do exactly what binary search would do and then modify that behavior to test the smallest value first. |
Ok. I have written something in the Haskell version: It gives this:
|
Here's the implementation if you're interested. integral :: forall m a. (MonadGen m, Integral a) => Range a -> m a
integral range =
let
appendOrigin :: Tree.TreeT (MaybeT (GenBase m)) a -> Tree.TreeT (MaybeT (GenBase m)) a
appendOrigin tree =
Tree.TreeT $ do
Tree.NodeT x xs <- Tree.runTreeT tree
pure $
Tree.NodeT x (xs <> [pure (Range.origin range)])
binarySearchTree :: a -> Tree.TreeT (MaybeT (GenBase m)) a -> Tree.TreeT (MaybeT (GenBase m)) a
binarySearchTree bottom tree =
Tree.TreeT $ do
Tree.NodeT x xs <- Tree.runTreeT tree
let
level =
Shrink.towards bottom x
zipped =
zipWith (\b a -> binarySearchTree b (pure a)) (level) (drop 1 level)
pure $
Tree.NodeT x (xs <> zipped)
withGenT' :: (GenT (GenBase m) a -> GenT (GenBase m) b) -> m a -> m b
withGenT' = withGenT
in
withGenT' (mapGenT (binarySearchTree (Range.origin range) . appendOrigin)) (integral_ range) |
Beautiful! You just wrote that code, right? Do you plan to merge that code in? |
Yes I did; and I will put a PR. It does looks quite a lot better to the eye. I'm doing some comparisons with how it goes in the examples at the moment (seeing if it reduces the average number of shrinks and works well with nesting products and sums, I expect it will do well). |
Excellent! :D I will participate in that PR review. After it is complete, I will return to PR #239. |
While attempting to reproduce the behavior described in #223, I think I found some surprising behavior.
My investigative goal was to see what is the sequence of inputs generated when shrinking a simple generator. Here is the code I tried.
The result of one execution of this test is...
...and the values written to
output
areThere are eight nonzero values, so they alone are the eight values involved in the seven shrinks.
Why is
0
tested so many times?The text was updated successfully, but these errors were encountered: