Skip to content
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

Change frequency generator to immediately shrink #406

Merged
merged 1 commit into from
Oct 20, 2020

Conversation

ocharles
Copy link
Contributor

@ocharles ocharles commented Oct 14, 2020

Change frequency generator immediately shrink

The frequency generators works by totalling all weights, and then
picking a random number between [0, total]. It then selects the first
generator who's cummulative weight matches this number. This works fine,
but the current implementation leads to poor shrinking behavior. In
particular, the shrinking is driven by repeatedly picking smaller
numbers in the range [0, total]. For skewed distributions, this can mean
repeatedly reselecting the same generator.

This commit makes the observation that the weight only matters for the
first pick. Once you have made a choice and observed a test failure, you
can give all generators equal weight, as we're going to perform a
breadth-first enumeration to try and find a shrink anyway.

Before this patch, we would see behavior like:

> printWith 30 (Seed 4 1) $ Hedgehog.Gen.maybe alphaNum
=== Outcome ===
Just 'o'
=== Shrinks ===
Nothing
Just 'o'
Just 'o'
Just 'o'
Just 'o'
Just 'a'
Just 'h'
Just 'l'
Just 'n'

Note that Just 'o' appears many times - this is because the maybe
generator puts a heavy bias towards 'Just' constructors.

With this commit, we get the much more minimal output:

> printWith 30 (Seed 4 1) $ Hedgehog.Gen.maybe alphaNum
=== Outcome ===
Just 'o'
=== Shrinks ===
Nothing
Just 'a'
Just 'h'
Just 'l'
Just 'n'

Note that no characters were duplicated.

@TysonMN
Copy link
Member

TysonMN commented Oct 14, 2020

Why does the outcome change? (Just 'o' vs Just 'I')

@ocharles
Copy link
Contributor Author

ocharles commented Oct 14, 2020

Oh, I meant to mention - I think this changes how the seed is used. I tried using a stable seed, but for some reason you get different results (though it's still stable). Does that make sense?

Basically, using seed=x on master gives different results to my branch, but repeatedly running my branch with seed=x will give the same result each time.

Edit: I think this is happening because of this new line:

gens <- traverse toTreeMaybeT (upTo n xs0)

which will end up splitting the seed for the call to gen

@ocharles ocharles changed the title Change frequency generator immediately shrink Change frequency generator to immediately shrink Oct 14, 2020

-- Pick a single generator from the weighted list of generators. We prune
-- here as shrinks no longer need to care about bias.
n <- prune $ integral $ Range.constant 1 total
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of integral and prune, just use integral_.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I didn't know about that!

@HuwCampbell
Copy link
Member

HuwCampbell commented Oct 14, 2020

Thanks Ollie. Cool investigation.

I've had a play and I think there's a slightly neater solution which doesn't require the tree hacking.

The intuition is the same, but just uses a modified shrink on integral to not include the duplicates.

frequency :: MonadGen m => [(Int, m a)] -> m a
frequency = \case
  [] ->
    error "Hedgehog.Gen.frequency: used with empty list"
  xs0 -> do
    let
      pick n = \case
        [] ->
          error "Hedgehog.Gen.frequency/pick: used with empty list"
        (k, x) : xs ->
          if n <= k then
            x
          else
            pick (n - k) xs

      iis =
        scanl1 (+) (fmap fst xs0)

      total =
        last iis

    n <- shrink (\n -> takeWhile (< n) iis) $ integral_ $ Range.constant 1 total
    pick n xs0

What do you think?

Edit: the list might be best reversed?

@ocharles
Copy link
Contributor Author

That sounds great! I certainly wasn't happy about how many guts I had to open up to get this to work. I'll have a play around with your code tomorrow and see what I can make of it.

@TysonMN
Copy link
Member

TysonMN commented Oct 14, 2020

I think this changes how the seed is used. I tried using a stable seed, but for some reason you get different results (though it's still stable). Does that make sense?

Basically, using seed=x on master gives different results to my branch, but repeatedly running my branch with seed=x will give the same result each time.

Yes, that makes sense. However, I don't understand why each case stopped where it did.

In your example, do all values correspond to a failed test? That is, does the shrinking keep happening until there is nothing left to shrink (not because the test passed)?

@ocharles
Copy link
Contributor Author

@TysonMN what you're seeing above is the output of print - https://hackage.haskell.org/package/hedgehog-1.0.3/docs/Hedgehog-Gen.html#v:print. This shows just one shrinking path. Perhaps you would like to see the output of printTree for an exhaustive enumeration of all shrinks? The way Hedgehog shrinking works is essentially a depth first traversal of the shrink tree, for as long as the test fails. The moment a test starts suceeding, we back track to the last failure and take the next alternative.

I forget exactly what print does, but I think it's essentially the left-most branch of the shrink tree.

@ocharles
Copy link
Contributor Author

@HuwCampbell Thanks for your suggestion, it looks like it works great!

> printTree $ frequency [(1, pure "a"), (3, pure "b"), (10, show <$> bool)]
"True"
 ├╼"a"
 ├╼"b"
 │  └╼"a"
 └╼"False"

Originally, it could be

"True"
 ├╼"a"
 ├╼"True"
 │  ├╼"a"
 │  ├╼"b"
 │  │  ├╼"a"
 │  │  └╼"b"
 │  │     └╼"a"
 │  ├╼"b"
 │  │  ├╼"a"
 │  │  ├╼"b"
 │  │  │  └╼"a"
 │  │  └╼"b"
 │  │     ├╼"a"
 │  │     └╼"b"
 │  │        └╼"a"
 │  └╼"False"
 ├╼"True"
 │  ├╼"a"
 │  ├╼"b"
 │  │  ├╼"a"
 │  │  ├╼"b"
 │  │  │  └╼"a"
 │  │  └╼"b"
 │  │     ├╼"a"
 │  │     └╼"b"
 │  │        └╼"a"
 │  ├╼"True"
 │  │  ├╼"a"
 │  │  ├╼"b"
 │  │  │  ├╼"a"
 │  │  │  └╼"b"
 │  │  │     └╼"a"
 │  │  ├╼"True"
 │  │  │  ├╼"a"
 │  │  │  ├╼"b"
 │  │  │  │  ├╼"a"
 │  │  │  │  └╼"b"
 │  │  │  │     └╼"a"
 │  │  │  ├╼"b"
 │  │  │  │  ├╼"a"
 │  │  │  │  ├╼"b"
 │  │  │  │  │  └╼"a"
 │  │  │  │  └╼"b"
 │  │  │  │     ├╼"a"
 │  │  │  │     └╼"b"
 │  │  │  │        └╼"a"
 │  │  │  └╼"False"
 │  │  └╼"False"
 │  └╼"False"
 ├╼"True"
 │  ├╼"a"
 │  ├╼"b"
 │  │  ├╼"a"
 │  │  ├╼"b"
 │  │  │  └╼"a"
 │  │  └╼"b"
 │  │     ├╼"a"
 │  │     └╼"b"
 │  │        └╼"a"
 │  ├╼"True"
 │  │  ├╼"a"
 │  │  ├╼"b"
 │  │  │  ├╼"a"
 │  │  │  └╼"b"
 │  │  │     └╼"a"
 │  │  ├╼"True"
 │  │  │  ├╼"a"
 │  │  │  ├╼"b"
 │  │  │  │  ├╼"a"
 │  │  │  │  └╼"b"
 │  │  │  │     └╼"a"
 │  │  │  ├╼"b"
 │  │  │  │  ├╼"a"
 │  │  │  │  ├╼"b"
 │  │  │  │  │  └╼"a"
 │  │  │  │  └╼"b"
 │  │  │  │     ├╼"a"
 │  │  │  │     └╼"b"
 │  │  │  │        └╼"a"
 │  │  │  └╼"False"
 │  │  └╼"False"
 │  ├╼"True"
 │  │  ├╼"a"
 │  │  ├╼"b"
 │  │  │  ├╼"a"
 │  │  │  ├╼"b"
 │  │  │  │  └╼"a"
 │  │  │  └╼"b"
 │  │  │     ├╼"a"
 │  │  │     └╼"b"
 │  │  │        └╼"a"
 │  │  ├╼"True"
 │  │  │  ├╼"a"
 │  │  │  ├╼"b"
 │  │  │  │  ├╼"a"
 │  │  │  │  └╼"b"
 │  │  │  │     └╼"a"
 │  │  │  ├╼"True"
 │  │  │  │  ├╼"a"
 │  │  │  │  ├╼"b"
 │  │  │  │  │  ├╼"a"
 │  │  │  │  │  └╼"b"
 │  │  │  │  │     └╼"a"
 │  │  │  │  ├╼"b"
 │  │  │  │  │  ├╼"a"
 │  │  │  │  │  ├╼"b"
 │  │  │  │  │  │  └╼"a"
 │  │  │  │  │  └╼"b"
 │  │  │  │  │     ├╼"a"
 │  │  │  │  │     └╼"b"
 │  │  │  │  │        └╼"a"
 │  │  │  │  └╼"False"
 │  │  │  └╼"False"
 │  │  └╼"False"
 │  └╼"False"
 └╼"False"

so this is much better!

@HuwCampbell
Copy link
Member

Yeah I'm pretty happy with the result. It does address the duplicates seen in #118 as well.

@HuwCampbell
Copy link
Member

@ocharles can you squash this to one commit with both of us as authors?

@ocharles
Copy link
Contributor Author

@HuwCampbell How does that look?

@HuwCampbell
Copy link
Member

Thanks! that second shrink example is probably off now though.

@ocharles
Copy link
Contributor Author

I think it's still pretty reasonable, it might not be exactly right, but it illustrates that there are no duplicates. I think the only material difference is that Nothing happens first, rather than last.

@HuwCampbell
Copy link
Member

I only mentioned because it was brought up that the initial value changed, while that's no longer the case.

@ocharles
Copy link
Contributor Author

Ah good point, I'll generate a new output.

The frequency generators works by totalling all weights, and then
picking a random number between [0, total]. It then selects the first
generator who's cummulative weight matches this number. This works fine,
but the current implementation leads to poor shrinking behavior. In
particular, the shrinking is driven by repeatedly picking smaller
numbers in the range [0, total]. For skewed distributions, this can mean
repeatedly reselecting the same generator.

This commit makes the observation that the weight only matters for the
first pick. Once you have made a choice and observed a test failure, you
can give all generators equal weight, as we're going to perform a
breadth-first enumeration to try and find a shrink anyway.

Before this patch, we would see behavior like:

```
> printWith 30 (Seed 4 1) $ Hedgehog.Gen.maybe alphaNum
=== Outcome ===
Just 'o'
=== Shrinks ===
Nothing
Just 'o'
Just 'o'
Just 'o'
Just 'o'
Just 'a'
Just 'h'
Just 'l'
Just 'n'
```

Note that `Just 'o'` appears many times - this is because the maybe
generator puts a heavy bias towards 'Just' constructors.

With this commit, we get the much more minimal output:

```
> printWith 30 (Seed 4 1) $ Hedgehog.Gen.maybe alphaNum
=== Outcome ===
Just 'o'
=== Shrinks ===
Nothing
Just 'a'
Just 'h'
Just 'l'
Just 'n'
```

Note that no characters were duplicated.

Co-authored-by: Huw Campbell <huw.campbell@gmail.com>
@ocharles
Copy link
Contributor Author

Commit and pull request updated ✍️

Copy link
Member

@moodmosaic moodmosaic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 👍 Great collab work as well 🎸

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants