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

Improve QCheck2 string shrinking #157

Open
sir4ur0n opened this issue Aug 17, 2021 · 2 comments
Open

Improve QCheck2 string shrinking #157

sir4ur0n opened this issue Aug 17, 2021 · 2 comments

Comments

@sir4ur0n
Copy link
Contributor

Sub-optimal string shrinkers

By design, with integrated shrinking a shrinker inherits the generator's structure. As a consequence, the reduced counterexamples are not as small as previously. Here's diff -y of string_never_has_000_char and string_never_has_255_char:

--- Failure -------------------------------------------------	--- Failure -------------------------------------------------

Test string never has a \000 char failed (25 shrink steps):   |	Test string never has a \000 char failed (22 shrink steps):

"\000"							      |	"aaaaaa\000aaaaaaaaaaaaaaaa"

--- Failure -------------------------------------------------	--- Failure -------------------------------------------------

Test string never has a \255 char failed (249 shrink steps):  |	Test string never has a \255 char failed (59 shrink steps):

"\255"							      |	"aaaaaaaaaaaaaaaaaaaaaaaaaa\255aaaaaaaaaaaaaaaaaaaaaaaa"

Since Gen.string ends up calling Gen.bytes_size - and since that generates a random size first and subsequently a string of that desired size, I believe the shrinker first investigates the shrink tree of size to find the smallest one to fail the property, and then fixes that while reducing the individual characters. This can be confirmed with a shrink log (here I just list successful string shrinks):

"\011\241\027<r\r\130\187\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"V\020\214t\235a\000hl\014z\200\243,\227\171Sg\172\211\244\183F"
"a\020\214t\235a\000hl\014z\200\243,\227\171Sg\172\211\244\183F"
"aa\214t\235a\000hl\014z\200\243,\227\171Sg\172\211\244\183F"
"aaat\235a\000hl\014z\200\243,\227\171Sg\172\211\244\183F"
"aaaa\235a\000hl\014z\200\243,\227\171Sg\172\211\244\183F"
"aaaaaa\000hl\014z\200\243,\227\171Sg\172\211\244\183F"
"aaaaaa\000al\014z\200\243,\227\171Sg\172\211\244\183F"
"aaaaaa\000aa\014z\200\243,\227\171Sg\172\211\244\183F"
... [some line omitted]
"aaaaaa\000aaaaaaaaaaaaa\244\183F"
"aaaaaa\000aaaaaaaaaaaaaa\183F"
"aaaaaa\000aaaaaaaaaaaaaaaF"
"aaaaaa\000aaaaaaaaaaaaaaaa"

The first line lists the first random string with a \000 found.
The second line lists a second, smaller random string with a \000 (size was shrunk).
At this point the generator cannot manage to find a smaller random string with a \000 so it starts reducing irrelevant characters to 'a'.
Interestingly, because the char generator in line 2 is started in a different state st the second counterexample bears no resemblance to the first! I think this is a good reason to reconsider using a splittable random number generator (issue #86) - or at least an approximation thereof using RS.copy.

Generally I think we should consider using a smarter default string shrinker, e.g., similar to those from QCheck.list and QCheck2.Shrink.number_towards. After all, since we know that Gen.char can produce any of these characters and sizes of 0-9999 with Gen.nat, it can also produce "\000".
In comparison, the QCheck shrink log reads:

"\011\241\027<r\r\130\187\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"\241\027<r\r\130\187\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"\027<r\r\130\187\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"<r\r\130\187\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"r\r\130\187\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"\r\130\187\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"\130\187\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"\187\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"\200'\000\181,\019qi7\193\029\195\218L\210\186_"
"'\000\181,\019qi7\193\029\195\218L\210\186_"
"\000\181,\019qi7\193\029\195\218L\210\186_"
"\000,\019qi7\193\029\195\218L\210\186_"
"\000\019qi7\193\029\195\218L\210\186_"
"\000qi7\193\029\195\218L\210\186_"
"\000i7\193\029\195\218L\210\186_"
"\0007\193\029\195\218L\210\186_"
"\000\193\029\195\218L\210\186_"
"\000\029\195\218L\210\186_"
"\000\195\218L\210\186_"
"\000\218L\210\186_"
"\000L\210\186_"
"\000\210\186_"
"\000\186_"
"\000_"
"\000"

Notice the different strategy of repeated modifications of the original counter example. As a side note we could improve QCheck.string's shrinker to use bisection instead, since this will take needlessly long for long strings).
Secondly, I noticed that the QCheck.string shrinker doesn't reduce the contained characters:

Test strings are empty failed (249 shrink steps):	      |	Test strings are empty failed (8 shrink steps):

"\177"							      |	"a"

So that's another possible improvement we could consider.

Originally posted by @jmid in #153 (comment)

@sir4ur0n
Copy link
Contributor Author

Indeed in my opinion this is another example of why Hedgehog carries the Size type, holding the bounds and origin, for every generator.

I guess a way to go would be to add the origin to string/char generators where applicable, and carry this information. This would allow using int_towards + convert from/to char.

This is also related to the current shrinking composition, so see #120

@jmid
Copy link
Collaborator

jmid commented Aug 19, 2021

Here's an alternative version of Gen.bytes_size:

  let bytes_size ?(gen = char) (size : int t) : bytes t = fun st ->
    let open Tree in
    let size_tree = size st in
    (* Adding char shrinks to a mutable list is expensive: ~20-30% cost increase *)
    (* Adding char shrinks to a mutable lazy list is less expensive: ~15% cost increase *)
    let char_trees_rev = ref [] in
    let bytes = Bytes.init (root size_tree) (fun _ ->
                    let char_tree = gen st in
                    char_trees_rev := char_tree :: !char_trees_rev ;
                    (* Performance: return the root right now, the heavy processing of shrinks can wait until/if there is a need to shrink *)
                    root char_tree) in
    let shrink = fun () ->
      let char_trees = List.rev !char_trees_rev in
      let char_list_tree = size_tree >>= fun new_size -> applicative_take new_size char_trees in
      let bytes_tree = char_list_tree >|= fun char_list -> List.to_seq char_list |> Bytes.of_seq in
      (* Technically [bytes_tree] is the whole tree, but for perf reasons we eagerly created the root above *)
      children bytes_tree ()
    in
    Tree (bytes, shrink)

Algorithmically it is almost identical to the previous version. Rather than try a shorter random string on a failure
it instead tries a prefix of the failed string. To do so, it still uses the size generator's shrink tree.
Before doing so it generates the bytes which are then constant/fixed throughout shrinking.
It thus avoids starting generation of chars for a smaller size in a new Random.State.
This makes shrinking more predictable and the shrink log more sane, IMO (here for test string_never_has_000_char):

"\137lk\019\159\153>\179\022Z\198\129\150\216\"\190\210\173n\234\245\196\005p\b\152A8@\021\156\r\244\248_0\166\235\168'\127\154$\025t\158\012\240\188L\238\212C\246\129\142\254\163?\202\167\207\187\250: \1836\192\207\223YH\149\024^u(X\191\213@\t\142\004\023\171\241\153\131\248G\182z,\178\162\222?(d\249<\243\229\001)\192\n\221T\186\255GX\165\239a\222\213\165\170\130\143M\239\b\245u\\m\2061\144\175\189[.]\021\246\244\173\146\230>\240\164\224\254\145^\128\164\204\185\218G\213pi\160\149@\\\249pCx\168\200\223lqV\160\236\1366/\228\167@\255\003H\127\012\199\142!\197\201\219@R\247\022\214d\212\187\141jj\231\229x;\021\226\165\236\031xi\244\025T${\134\"6\129\003+f\141\027\014\206P\190\152^\1607`TaO\184\193\174\175;\206\234\177"
"\248\177V\184\200\206\244\015\238I\127\205\129\007\1419\217"
"\011\241\027<r\r\130\187\151\200'\000\181,\019qi7\193\029\195\218L\210\186_"
""
"\011\241\027<r\r\130\187\151\200'\000\181"
""
"\011\241\027<r\r"
"\011\241\027<r\r\130\187\151"
"\011\241\027<r\r\130\187\151\200'"
"\011\241\027<r\r\130\187\151\200'\000"
""
"\011\241\027<r\r"
"\011\241\027<r\r\130\187\151"
"\011\241\027<r\r\130\187\151\200'"
"a\241\027<r\r\130\187\151\200'\000"
"aa\027<r\r\130\187\151\200'\000"
"aaa<r\r\130\187\151\200'\000"
"aaaar\r\130\187\151\200'\000"
"aaaaa\r\130\187\151\200'\000"
"aaaaaa\130\187\151\200'\000"
"aaaaaaa\187\151\200'\000"
"aaaaaaaa\151\200'\000"
"aaaaaaaaa\200'\000"
"aaaaaaaaaa'\000"
"aaaaaaaaaaa\000"
"aaaaaaaaaaaa"
"aaaaaaaaaaa1"
"aaaaaaaaaaa\025"
"aaaaaaaaaaa\r"
"aaaaaaaaaaa\007"
"aaaaaaaaaaa\004"
"aaaaaaaaaaa\002"
"aaaaaaaaaaa\001"

In the third attempt it finds a string containing a '\000' (the first line starting "\011...).
From here on it continues with size reduction and then char shrinking (a mix of successful and unsuccessful attempts).

It is relatively efficient on my machine. I just cranked up a Gen.string test to >100_000 iterations and used time.
How have you measured @sir4ur0n?

Algorithmically, it could be extended to try other sub-strings than prefixes (suffixes, middle-chunks?).
All these have the property that

  • they have a size generatable by the argument size generator (starting in a suitable Random.State)
  • their content would also be generatable by consecutive calls to the argument char generator (starting in a suitable Random.State).
    Cutting out chars randomly in the middle should therefore probably be avoided.

Since we are, in effect, just reducing char lists, I would probably argue that we should put in an effort for a general QCheck2.Shrink.list shrinker and then let strings, functions, etc. all benefit.

What do you think, @sir4ur0n, @c-cube?

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

No branches or pull requests

2 participants