Skip to content

Commit

Permalink
refactor into Shrinker module using chunk manipulation functions in H…
Browse files Browse the repository at this point in the history
…istory module
  • Loading branch information
CoderDennis committed Jan 22, 2024
1 parent 7c89eda commit 02c8d4a
Show file tree
Hide file tree
Showing 9 changed files with 391 additions and 168 deletions.
41 changes: 14 additions & 27 deletions NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ With no history it needs to use the same seed as ExUnit, which happens automatic

- [ ] Format raised error message to include generated values and shrinking statistics.

- [ ] How do we get a meaningful stacktrace? Does that even matter?

- [x] Get end to end with shrinking working with single integer generator.

- [x] Add `list_of` with shrinking on a list of integers. Use "list is sorted" as the property which should shrink to `[1,0]`.
Expand All @@ -63,15 +65,15 @@ With no history it needs to use the same seed as ExUnit, which happens automatic

- [x] Keep track of seen histories to avoid trying them again.

- [ ] Change `History.shrink_length/1` to remove varying sized chunks.
- [x] Try a new implementation of shrinking. Create multiple histories from a given history. Test all of them against test_fn.
Keep best (shortlex smallest) that still fails the test and re-start the shrinking process with that one as the input.
Copy more of the elm-test implementation. Create a `Shrinker` module.

- [ ] Change `History.shrink_length/1` to remove segments from within the history instead of only at the beginning.
- [x] Put raw chunk manipulation functions in `History` and test them.

- [ ] Try a new implementation of shrinking. Create multiple histories from a given history. Test all of them against test_fn.
Keep best (shortlex smallest) that still fails the test and re-start the shrinking process with that one as the input.
Maybe copy more of the elm-test implementation. Create a `Simplify` module or keep shrinking in the `History` module?
- [x] Implement binary search for finding smaller interesting values within the PRNG history.

- [ ] Put raw chunk manipulation functions in `History` and test those first.
- [ ] Store `length` in `History` struct? This would make some operations more efficient.

- [ ] Add support for generating integers larger than the internal representation of the PRNG history which is currently a 32-bit integer. This requires consuming more than one value. The `next` funciton probably needs a byte_count parameter.

Expand Down Expand Up @@ -103,6 +105,8 @@ Maybe flatten the structure while keeping `random/0` and `hardcoded/1` construct

- [ ] Publish to Hex.pm

- [ ] add `mix dialyzer` to GitHub action

### How do we make generators composible?

Users should be able to create new generators based on the library generators.
Expand Down Expand Up @@ -141,15 +145,15 @@ How does it find possible smaller histories? By using some set of strategies or

When it finds a valid smaller history, then starts over with that one.

Individually shrink integers. Try zero, divide by 2, subtract 1, and removing from history.
Individually shrink integers using binary search.

Keep track of histories that have been used/seen to avoid retrying them.
Keep track of histories that have been used/seen to avoid retrying them. After refactoring into `Shrinker` module, this was not necessary.

Do we shrink the first value and then shrink the rest of the history? That doesn't really work. Some shrinking needs to operate on later values only.

Only feed used history into next round of shrinking? Discard unused values at the end of history.
- [ ] Only feed used history into next round of shrinking? Discard unused values at the end of history.

Storing larger integers might make shrinking less efficient because it takes longer to reach low values.
Storing larger integers might make shrinking less efficient because it takes longer to reach low values. Binary search solves this issue.

Is the process of rerunning the test and trying further shrinking similar to genetic algorithms?
For a given test, this shouldn’t need to be parallelized.
Expand Down Expand Up @@ -179,20 +183,3 @@ Just use `check_all` directly without the macros. Add macros later.
How important is it for `Decorum.uniform_integer/1` to produce uniformly random values?
I tried the code from https://rosettacode.org/wiki/Verify_distribution_uniformity/Chi-squared_test and its `chi2IsUniform/2` function returned false for all the examples I ran.
The `chi2Probability/2` results were around `1.69e-13` when they were expected to be greater than `0.05`.

```elixir
# non-stream version of shrinking a single integer
def shrink_int(i) do
shrink_int(i - 1, MapSet.new([0])) |> Enum.reverse()
end

defp shrink_int(i, seen) when i < 0, do: seen

defp shrink_int(i, seen) do
if MapSet.member?(seen, i) do
seen
else
shrink_int(div(i, 2), seen |> MapSet.put(i) |> MapSet.put(i - 1))
end
end
```
109 changes: 39 additions & 70 deletions lib/decorum.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,21 @@ defmodule Decorum do
Documentation for `Decorum`.
"""

alias Decorum.History
alias Decorum.Prng
alias Decorum.Shrinker

@type generator_fun(a) :: (Prng.t() -> {a, Prng.t()})
@type value :: term()

@type t(a) :: %__MODULE__{generator: generator_fun(a)}
@type generator_fun(value) :: (Prng.t() -> {value, Prng.t()})

@type t(value) :: %__MODULE__{generator: generator_fun(value)}

defstruct [:generator]

@doc """
Helper for creating Decorum structs from a generator function.
"""
@spec new(generator_fun(a)) :: t(a) when a: term()
@spec new(generator_fun(value)) :: t(value)
def new(generator) when is_function(generator, 1) do
%__MODULE__{generator: generator}
end
Expand All @@ -26,7 +28,7 @@ defmodule Decorum do
Takes a Decorum struct and a Prng struct and returns a lazy Enumerable
of generated values.
"""
@spec stream(t(a), Prng.t()) :: Enumerable.t(a) when a: term()
@spec stream(t(value), Prng.t()) :: Enumerable.t(value)
def stream(%__MODULE__{generator: generator}, prng) do
Stream.unfold(prng, generator)
end
Expand All @@ -43,71 +45,37 @@ defmodule Decorum do
TODO: Create a Decorum.Error and raise that instead of ExUnit.AssertionError.
"""
@spec check_all(t(a), (a -> nil)) :: :ok when a: term()
def check_all(%__MODULE__{generator: generator}, test_fn) do
@spec check_all(t(value), (value -> nil)) :: :ok
def check_all(%__MODULE__{generator: generator}, test_fn) when is_function(test_fn, 1) do
1..100
|> Enum.each(fn _ ->
{value, prng} = generator.(Prng.random())

try do
test_fn.(value)
rescue
exception ->
try do
shrink(Prng.get_history(prng), generator, test_fn, MapSet.new())
rescue
shrunk_exception ->
reraise shrunk_exception, __STACKTRACE__
else
_ -> reraise exception, __STACKTRACE__
end
case check(test_fn, value) do
{:error, message} ->
Shrinker.shrink(check(test_fn), generator, value, Prng.get_history(prng), message)

:ok ->
:ok
end
end)
end

defp shrink([], _generator, _test_fn, _seen), do: :ok

defp shrink(history, generator, test_fn, seen) do
{seen, new_history, exception, stacktrace} =
history
|> History.shrink()
|> Enum.take(200)
|> Enum.reduce_while({seen, history, nil, nil}, fn hist, {seen, _, _, _} ->
if MapSet.member?(seen, hist) do
{:cont, {seen, history, nil, nil}}
else
seen = MapSet.put(seen, hist)

try do
{value, _} = generator.(Prng.hardcoded(hist))
test_fn.(value)
{:cont, {seen, hist, nil, nil}}
rescue
Decorum.Prng.EmptyHistoryError ->
{:cont, {seen, history, nil, nil}}

exception ->
{:halt, {seen, hist, exception, __STACKTRACE__}}
end
end
end)

if new_history != history do
try do
shrink(new_history, generator, test_fn, seen)
rescue
shrunk_exception ->
reraise shrunk_exception, __STACKTRACE__
else
_ ->
if exception != nil do
reraise exception, stacktrace
else
:ok
end
end
else
@spec check((value -> nil), value) :: Shrinker.check_result()
def check(test_fn, test_value) when is_function(test_fn, 1) do
try do
test_fn.(test_value)
:ok
rescue
exception ->
{:error, exception.message}
end
end

@spec check((value -> nil)) :: Shrinker.check_function(value)
def check(test_fn) do
fn test_value ->
check(test_fn, test_value)
end
end

Expand All @@ -126,7 +94,7 @@ defmodule Decorum do
@doc """
Create a generator that is not random and always returns the same value.
"""
@spec constant(a) :: t(a) when a: term()
@spec constant(value) :: t(value)
def constant(value) do
new(fn prng -> {value, prng} end)
end
Expand Down Expand Up @@ -180,7 +148,7 @@ defmodule Decorum do
`generators` must be a list.
"""
@spec one_of([t(a)]) :: t(a) when a: term()
@spec one_of([t(value)]) :: t(value)
def one_of([]) do
raise "one_of needs at least one item"
end
Expand All @@ -203,7 +171,7 @@ defmodule Decorum do
Use a biased coin flip to determine if another value should be gerenated
or the list should be terminated.
"""
@spec list_of(t(a)) :: t([a]) when a: term()
@spec list_of(t(value)) :: t([value])
def list_of(%Decorum{generator: generator}) do
new(fn prng ->
Stream.cycle(1..1)
Expand All @@ -220,7 +188,7 @@ defmodule Decorum do
end)
end

@spec list_of_length(t(a), non_neg_integer()) :: [t(a)] when a: term()
@spec list_of_length(t(value), non_neg_integer()) :: t(list(value))
def list_of_length(decorum, length) do
Stream.repeatedly(fn -> decorum end)
|> Enum.take(length)
Expand Down Expand Up @@ -257,7 +225,7 @@ defmodule Decorum do
`fun` is a function that takes a value from the given generator and
returns a generator.
"""
@spec and_then(t(a), (a -> t(b))) :: t(b) when a: term(), b: term()
@spec and_then(t(a), (a -> t(b))) :: t(b) when a: value, b: value
def and_then(%Decorum{generator: generator}, fun) when is_function(fun, 1) do
new(fn prng ->
{value, prng} = generator.(prng)
Expand All @@ -272,7 +240,7 @@ defmodule Decorum do
Returns a generator where each element is the result of invoking fun
on each corresponding element of the given generator.
"""
@spec map(t(a), (a -> b)) :: t(b) when a: term(), b: term()
@spec map(t(a), (a -> b)) :: t(b) when a: value, b: value
def map(%Decorum{generator: generator}, fun) when is_function(fun, 1) do
new(fn prng ->
{value, prng} = generator.(prng)
Expand All @@ -285,7 +253,7 @@ defmodule Decorum do
Zips corresponding elements from two generators into a generator of tuples.
"""
@spec zip(t(a), t(b)) :: t({a, b}) when a: term(), b: term()
@spec zip(t(a), t(b)) :: t({a, b}) when a: value, b: value
def zip(%Decorum{generator: generator_a}, %Decorum{generator: generator_b}) do
new(fn prng ->
{value_a, prng} = generator_a.(prng)
Expand Down Expand Up @@ -325,7 +293,8 @@ defmodule Decorum do
end

defp loop_until(_prng, _generator, _fun, 0) do
raise FilterTooNarrowError, "Decorum.filter did not find a matching value. Try widening the filter."
raise FilterTooNarrowError,
"Decorum.filter did not find a matching value. Try widening the filter."
end

defp loop_until(prng, generator, fun, limit) do
Expand All @@ -346,7 +315,7 @@ defmodule Decorum do
Use `limit` to specify how many times the generator should be called before raising an error.
"""
@spec filter(t(a), (a -> boolean)) :: t(a) when a: term()
@spec filter(t(value), (value -> boolean)) :: t(value)
def filter(%Decorum{generator: generator}, fun, limit \\ 25) do
new(fn prng ->
loop_until(prng, generator, fun, limit)
Expand Down

0 comments on commit 02c8d4a

Please sign in to comment.