-
Notifications
You must be signed in to change notification settings - Fork 37
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
Less exhaustive string shrinking #278
Comments
FWIW I'm using the following string generator/shrinker in my code, it makes the shrinker work reasonably fast, although it is not a general purpose shrinker. I tried to make 'shrink_all_chars' apply Shrink.char to each char just once, but even that is too slow, even if I write a one-step shrinker using just let truncated_str s =
if String.length s < 6 then
String.escaped s
else
Printf.sprintf "%s…(len:%d)"
(String.sub s 0 6 |> String.escaped)
(String.length s)
let all_bytes = String.init 256 Char.chr
let repeat n = List.init n (fun _ -> all_bytes) |> String.concat ""
let shrink_string_length str =
let open QCheck in
str |> String.length |> Shrink.int |>
QCheck.Iter.map (String.sub str 0)
let shrink_all_chars str yield =
(* shrinking each char individually would yield too many possibilities
on long strings if shrinking doesn't reproduce the bug anymore.
Instead shrink all chars at once, with a single result.
It will eventually converge on 'aa...a'.
*)
let next = String.make (String.length str) 'a' in
(* avoid infinite iteration: stop when target reached *)
if not (String.equal next str) then
yield next
let shrink_string str yield =
shrink_string_length str yield;
shrink_all_chars str yield
let bounded_string_arb n =
(* QCheck.Shrink.string is very slow: first converts to list of chars.
In our case bugs come from 3 sources:
* the byte values (e.g. ASCII vs non-ascii)
* the length of the string (whether it hits various limits or not)
* the uniqueness of the string (e.g. duplicate inserts)
Generate a small fully random string of fixed size, and concatenate it with a random length substring of a static string.
Shrinking will take care of producing smaller and more readable results as needed,
even below the fixed size
*)
assert (n > 4) ;
let n = n - 4 in
let long = ref [] in
let () = if n > 0 then
(* pregenerate all the long strings by running the shrinker on a static string *)
let max_repetitions = n / String.length all_bytes in
let max_str =
repeat max_repetitions
^ String.sub all_bytes 0 (n mod String.length all_bytes)
in
shrink_string_length max_str (fun s -> long := s :: !long)
in
let gen_long = QCheck.Gen.oneofa @@ Array.of_list !long in
let gen_string =
let open QCheck.Gen in
let* small = string_size @@ return 4 in
let+ long = gen_long in
small ^ long
in
QCheck.(make
~print:truncated_str
~small:String.length
~shrink:shrink_string
gen_string) |
I think it might be useful to guide the shrinker based on the kind of failure we're shrinking.
OTOH if the failure says something invalid about the string itself then you need to try both methods (and hopefully length based shrinking would first cut down the length considerably...) This would need some kind of user provided exception classifier, and perhaps too specific and would complicate the API too much. It could also try to "optimistically" jump ahead, e.g. if previously shrinking succeeded to reduce length to N bytes, then for the next parameter try length N directly (if it doesn't work fall back to Shrink.int as usual, perhaps noting that N already failed and stop there). Finally for shrinking list of strings "delta debugging" techniques might be useful (these are usually used to shrink source code for compilers after splitting by toplevel forms): https://github.com/mpflanzer/delta. Finally: why list of chars instead of array of chars? Surely arrays can be indexed more efficiently than lists, or in fact Bytes might be the most efficient form for shrinking. |
Thanks for sharing tips, feedback, and pointers! At the moment, in QCheck(1) we are using the interface in which shrinkers are The repeated restarting also explains why an O(n log n) iterator function is too eager/exhaustive, if it is restarted, say O(n) times, we end up spending O(n^2 log n).... 😬 We did have a more exhaustive list strinker at some point, but its performance was terrible on larger lists once we started measuring in #177. The shrinker improvements in #242 (and slightly tweaked in yesterday's #277) thus sticks to a sub-linear O(log n) iterator function for reducing the length of lists and (transitively) strings.
Good point, I completely agree that the |
Indeed there is no convenient place to store the "granularity" (unless we do something really "unfunctional" and use an ephemeron to store the last granularity for a particular generated string, or other kinds of globals...), and without it shrinking would perform a lot of unnecessary additional steps (as mentioned in the paper). open QCheck
(** [[start, stop)] interval from start to stop, start is included and stop is excluded *)
type intervals = (int*int) Iter.t
let interval_neq (s1,e1) (s2,e2) =
not (Int.equal s1 s2 && Int.equal e1 e2)
let intervals_to_string s : intervals Iter.t -> string Iter.t =
let b = Buffer.create (String.length s) in
Iter.map @@ fun intervals ->
Buffer.clear b;
(* TODO: this could be further optimized to coalesce adjacent intervals so we can blit bigger pieces in one go *)
let () = intervals @@ fun (start,stop) ->
Buffer.add_substring b s start (stop-start)
in
Buffer.contents b
let increase_granularity : intervals -> intervals = fun intervals yield ->
intervals @@ fun (start, stop) ->
let mid = (start + stop) lsr 1 in (* avoids negative overflow *)
assert (start <> mid);
yield (start, mid);
yield (mid, stop)
let rec shrink_intervals (n, test_complements) intervals : intervals Iter.t =
let n, intervals = n lsr 1, increase_granularity intervals in
if n = 0 then Iter.empty
else
let complement interval =
Iter.filter (interval_neq interval) intervals
in
fun yield ->
(* test subsets *)
Iter.map Iter.return intervals yield;
(* test complements *)
if test_complements then
Iter.map complement intervals yield;
(* increase granularity *)
shrink_intervals (n, true) intervals yield
let shrink_intervals start stop =
shrink_intervals (stop, false) @@ Iter.return (start, stop)
let shrink_string_delta s =
let intervals_to_string = intervals_to_string s in
shrink_intervals 0 (String.length s)
|> intervals_to_string
let test_f s =
print_endline s;
not (
String.contains s '1' &&
String.contains s '7' &&
String.contains s '8'
)
let () =
let str_test =
QCheck.make ~print:Fun.id ~small:String.length
~shrink:shrink_string_delta
(QCheck.Gen.return "12345678")
in
let str_test0 =
QCheck.make ~print:Fun.id ~small:String.length
~shrink:QCheck.Shrink.string
(QCheck.Gen.return "12345678")
in
QCheck_runner.run_tests_main
[ QCheck.Test.make str_test test_f
; QCheck.Test.make str_test0 test_f
] However even if we don't emulate delta debugging it might be worthwhile to reduce a set of intervals first (as shown above those intervals don't need to be stored, they can be an |
Thanks for sharing! utop # for i=1 to 20 do shrink_string_delta (String.make i 'X') (fun _ -> print_char '#'); print_newline () done;;
##
##
##########
##########
##########
##########
##########################
##########################
##########################
##########################
##########################
##########################
##########################
##########################
##########################################################
##########################################################
##########################################################
##########################################################
########################################################## The latter hurts (as you pointed out earlier), when repeatedly restarting the iterator as QCheck(1)'s shrinker does. Here's a plot for comparison (if we disregard the element shrinking): utop # for i=1 to 20 do Shrink.string ~shrink:Shrink.nil (String.make i 'X') (fun _ -> print_char '#'); print_newline () done;;
#
###
####
####
#####
#####
#####
#####
######
######
######
######
######
######
######
######
#######
#######
#######
####### As I see it, this nice logarithmic ASCII curve is killed by bolting on a linear-time utop # for i=1 to 20 do Shrink.string ~shrink:Shrink.char (String.make i 'X') (fun _ -> print_char '#'); print_newline () done;;
####
#########
#############
################
####################
#######################
##########################
#############################
#################################
####################################
#######################################
##########################################
#############################################
################################################
###################################################
######################################################
##########################################################
#############################################################
################################################################
################################################################### Earlier today, I was trying out a strategy to "zero-out" blocks of sqrt size with 'a's: utop # let rec all_as s i j =
if i>=j
then true
else s.[i] == 'a' && all_as s (i+1) j
let string_shrink char_shrink s yield =
let open QCheck in
let a_count = String.fold_left (fun count c -> if c = 'a' then count+1 else count) 0 s in
let len = String.length s in
let len_sqrt = int_of_float (ceil (sqrt (float_of_int len))) in
if len <= 5 || a_count >= len - len_sqrt
then Shrink.string ~shrink:char_shrink s yield
else
begin
Shrink.string ~shrink:Shrink.nil s yield;
let rec loop i =
if i < len
then
begin
(if not (all_as s i (i+len_sqrt))
then
let s = String.init len (fun j -> if i <= j && j < i+len_sqrt then 'a' else s.[j]) in
yield s);
loop (i+len_sqrt)
end
in
loop 0
end
utop # string_shrink Shrink.char "zzzzzzzzzzzzzzzzz" print_endline;;
zzzzzzzzz
zzzzzzzzzzzzz
zzzzzzzzzzzzzzz
zzzzzzzzzzzzzzzz
zzzzzzzzzzzzzzz
zzzzzzzzzzzzzzzz
zzzzzzzzzzzzzzzz
aaaaazzzzzzzzzzzz
zzzzzaaaaazzzzzzz
zzzzzzzzzzaaaaazz
zzzzzzzzzzzzzzzaa
This scales better as the size goes up AFAICS: utop # for i=1 to 20 do string_shrink Shrink.char (String.make i 'X') (fun _ -> print_char '#'); print_newline () done;;
####
#########
#############
################
####################
#######
########
########
#########
#########
#########
#########
##########
##########
##########
##########
###########
###########
###########
########### Caveats:
|
Perhaps the choice of char shrinker (or its depth) could be given to the end-user of the API (with a good default...), e.g. have the ability to turn off char shrinking completely, which I don't think is currently possible (if you leave it as none you get a default shrinker instead of no shrinker), although I'm not sure how to do that in a compatible way with the current API. Then perhaps some heuristics could be used based on the length of the string on what kind of char shrinker to use, e.g. we could give the shrinker a certain amount of "fuel", which it can spend as it best sees fit: either for shrinking the length of the string, or once that is exhausted for shrinking characters. So in that sense length shrinking would consume 'O(1)' fuel for allocation (it calls the allocation function once, for simplicity lets assume that allocating 1MiB is as cheap as allocating 1 byte, which might be even true if it all fits in the minor heap and allocated by the bump allocator), and 'O(n)' processing time, although O(1) function calls to perform that processing. There is a balance here on how to measure things, so for simplicity I propose to consume fuel in this way instead:
And the idea of 'fuel' is already part of how recursive data structures are generated, so why not use that idea for shrinking as well? (especially that in QCheck2 it looks like shrinking works on a tree-like structure and it can probably track fuel internally without having to change the 'set_shrink' API ...). Then small values would do more exhaustive shrinking, and large values (that cannot be further shrinked in length) would perform more limited shrinking. What do you think? |
You can use Shrink.string ~shrink:Shrink.nil as I do above in the second plot. I also realize now that my message above was unclear: From having spent time collecting the shrinker benchmarks in #177 and later trying to improve the QCheck(1) shrinker performance, allocations and processing aren't as dominating as the number of emitted shrinking candidates.
It sounds intriguing - but also ambitious! |
As the new
QCheck.Shrink.string
unit tests document, on a failure path (going through all shrinking candidates without luck) the current approach may be a bit too exhaustive:qcheck/test/core/QCheck_unit_tests.ml
Lines 93 to 103 in f37621a
By falling back on
Shrink.list_spine
we get a nice and short candidate sequence when we stay clear of element reduction:However, exhaustively trying to reduce all O(n) entries, say O(log n) times, brings this to O(n log n) which may be a bit much:
We are getting bitten by this in ocaml-multicore/multicoretests, as remarked by @edwintorok here: ocaml-multicore/multicoretests#329 (comment)
As an alternative to avoiding character-shrinks above a certain threshold, one could consider alternative approaches, e.g.
'a'
charchar
s simultaneously"zzzzz"
-> "aaazz"The text was updated successfully, but these errors were encountered: