mu
change
commit 59dbb8262b879ca8f2d9383a76d0a8b1f6da7dd5
author Alpha Chen <alpha@kejadlen.dev>
date
parent 600f60ab
diff --git a/lib/minitest/thesis.rb b/lib/minitest/thesis.rb
index 0e3ac93..08583db 100644
--- a/lib/minitest/thesis.rb
+++ b/lib/minitest/thesis.rb
@@ -2,10 +2,16 @@ require "digest"
 
 require "minitest"
 
+require_relative "thesis/directory_db"
+require_relative "thesis/error"
+require_relative "thesis/possibility"
+require_relative "thesis/status"
+require_relative "thesis/test_case"
+require_relative "thesis/testing_state"
+require_relative "thesis/version"
+
 module Minitest
   module Thesis
-    VERSION = "0.1.0"
-
     class Test < Minitest::Test
 
       # Runs a test. Usage is:
@@ -72,161 +78,6 @@ module Minitest
         end
       end
 
-      # Represents a single generated test case, which consists of an underlying
-      # set of choices that produce possibilities.
-      class TestCase
-
-        # Returns a test case that makes this series of choices.
-        def self.for_choices(choices, print_results: false)
-          self.new(prefix: choices, random: nil, max_size: choices.length, print_results:)
-        end
-
-        attr_accessor :status
-        attr_reader :choices, :targeting_score
-
-        def initialize(prefix:, random:, max_size: Float::INFINITY, print_results: false)
-          @prefix, @random, @max_size, @print_results = prefix, random, max_size, print_results
-          @choices = []
-          @status = nil
-          @depth = 0
-          @targeting_score = nil
-        end
-
-        # Returns a number in the range [0, n]
-        def choice(n)
-          result = make_choice(n) { @random.rand(n) }
-
-          puts "choice(#{n}): #{result}" if should_print?
-
-          result
-        end
-
-        # Return True with probability `p`.
-        def weighted(p)
-          result = if    p <= 0 then forced_choice(0)
-                   elsif p >= 1 then forced_choice(1)
-                   else              make_choice(1) { (@random.rand <= p) ? 1 : 0 }
-                   end
-
-          puts "weighted(#{p}): #{result}" if should_print?
-
-          result
-        end
-
-        # Inserts a fake choice into the choice sequence, as if some call to
-        # choice() had returned `n`. You almost never need this, but sometimes it
-        # can be a useful hint to the shrinker.
-        def forced_choice(n)
-          raise RangeError.new("Invalid choice #{n}") if n.bit_length > 64 || n.negative?
-          raise Frozen unless @status.nil?
-
-          mark_status(Status::OVERRUN) if @choices.length >= @max_size
-
-          choices << n
-          n
-        end
-
-        # Mark this test case as invalid.
-        def reject = mark_status(Status::INVALID)
-
-        # If this precondition is not met, abort the test and mark this test case as invalid.
-        def assume(precondition)
-          return if precondition
-          reject
-        end
-
-        # Set a score to maximize. Multiple calls to this function will override previous ones.
-        #
-        # The name and idea come from Löscher, Andreas, and Konstantinos Sagonas.
-        # "Targeted property-based testing." ISSTA. 2017, but the implementation
-        # is based on that found in Hypothesis, which is not that similar to
-        # anything described in the paper.
-        def target(score) = @targeting_score = score
-
-        # Return a possible value from `possibility`.
-        def any(possibility)
-          begin
-            @depth += 1
-            result = possibility.produce.(self)
-          ensure
-            @depth -= 1
-          end
-
-          puts "any(#{possibility}): #{result}" if should_print?
-
-          result
-        end
-
-        # Set the status and raise StopTest.
-        def mark_status(status)
-          raise Frozen unless self.status.nil?
-
-          @status = status
-          raise StopTest
-        end
-
-        private
-
-        def should_print? = @print_results && @depth.zero?
-
-        # Make a choice in [0, n], by calling rnd_method if randomness is needed.
-        def make_choice(n, &rnd_method)
-          raise RangeError.new("Invalid choice #{n}") if n.bit_length > 64 || n.negative?
-          raise Frozen unless @status.nil?
-
-          mark_status(Status::OVERRUN) if @choices.length >= @max_size
-
-          result = if @choices.length < @prefix.length
-                     @prefix[@choices.length]
-                   else
-                     rnd_method.()
-                   end
-          @choices << result
-
-          mark_status(Status::INVALID) if result > n
-
-          result
-        end
-      end
-
-      # Represents some range of values that might be used in a test, that can be
-      # requested from a `TestCase`. Pass one of these to TestCase.any to get a
-      # concrete value.
-      class Possibility
-        attr_reader :produce, :name
-
-        def initialize(name = "TODO", &produce)
-          @name = name
-          @produce = produce
-        end
-
-        def inspect = name
-        def to_s = name
-
-        # "Returns a `Possibility` where values come from applying `f` to some possible value for `self`."
-        def map(&f)
-          self.class.new("#{name}.map(TODO)") {|tc| f.call(tc.any(self)) }
-        end
-
-        # Returns a `Possibility` where values come from applying `f` (which
-        # should return a new `Possibility` to some possible value for `self`
-        # then returning a possible value from that.
-        def bind(&f)
-          self.class.new("#{name}.bind(TODO)") {|tc| tc.any(f.(tc.any(self))) }
-        end
-
-        # Returns a `Possibility` whose values are any possible value of `self`
-        # for which `f` returns True.
-        def satisfying(&f)
-          self.class.new("#{name}.select(TODO)") {|test_case|
-            3.times.first {
-              candidate = test_case.any(self)
-              candidate if f.(candidate)
-            } || test_case.reject
-          }
-        end
-      end
-
       # Any integer in the range [m, n] is possible
       def integers(m, n) = Possibility.new("integers(#{m}, #{n})") {|tc| m + tc.choice(n - m) }
 
@@ -271,480 +122,6 @@ module Minitest
           possibilities.map {|p| tc.any(p) }
         }
       end
-
-      # We cap the maximum amount of entropy a test case can use.
-      # This prevents cases where the generated test case size explodes
-      # by effectively rejection
-      BUFFER_SIZE = 8 * 1024
-
-      # Returns a cached version of a function that maps a choice sequence to the
-      # status of calling a test function on a test case populated with it. Is
-      # able to take advantage of the structure of the test function to predict
-      # the result even if exact sequence of choices has not been seen
-      # previously.
-      #
-      # You can safely omit implementing this at the cost of somewhat increased
-      # shrinking time.
-      class CachedTestFunction
-        def initialize(&test_function)
-          @test_function = test_function
-
-          # Tree nodes are either a point at which a choice occurs
-          # in which case they map the result of the choice to the
-          # tree node we are in after, or a Status object indicating
-          # mark_status was called at this point and all future
-          # choices are irrelevant.
-          #
-          # Note that a better implementation of this would use
-          # a Patricia trie, which implements long non-branching
-          # paths as an array inline. For simplicity we don't
-          # do that here.
-          @tree = {}
-        end
-
-        def call(choices)
-          node = @tree
-          begin
-            choices.each do |c|
-              node = node.fetch(c)
-              # mark_status was called, thus future choices
-              # will be ignored.
-              if node.is_a?(Status)
-                fail if node == Status::OVERRUN
-                return node
-              end
-            end
-            # If we never entered an unknown region of the tree
-            # or hit a Status value, then we know that another
-            # choice will be made next and the result will overrun.
-            return Status::OVERRUN
-          rescue KeyError
-          end
-
-          # We now have to actually call the test function to find out what
-          # happens.
-          test_case = TestCase.for_choices(choices)
-          @test_function.(test_case)
-          fail if test_case.status.nil?
-
-          # We enter the choices made in a tree.
-          node = @tree
-          *rest, last = test_case.choices
-          rest.each do |c|
-            node = if node.has_key?(c)
-                     node[c]
-                   else
-                     node[c] = {}
-                   end
-          end
-          unless last.nil?
-            node[last] = test_case.status == Status::OVERRUN ? {} : test_case.status
-          end
-
-          test_case.status
-        end
-      end
-
-      class TestingState
-        attr_reader :result, :valid_test_cases, :calls
-
-        def initialize(random:, test_function:, max_examples:)
-          @random, @_test_function, @max_examples = random, test_function, max_examples
-          @valid_test_cases = 0
-          @calls = 0
-          @test_is_trivial = false
-        end
-
-        def test_function(test_case)
-          begin
-            @_test_function.(test_case)
-          rescue StopTest
-          end
-
-          if test_case.status.nil?
-            test_case.status = Status::VALID
-          end
-
-          @calls += 1
-
-          if test_case.status >= Status::INVALID && test_case.choices.length.zero?
-            @test_is_trivial = true
-          end
-
-          if test_case.status >= Status::VALID
-            @valid_test_cases += 1
-
-            unless test_case.targeting_score.nil?
-              relevant_info = [test_case.targeting_score, test_case.choices]
-              if @best_scoring.nil?
-                @best_scoring = relevant_info
-              else
-                best, _ = @best_scoring
-                if test_case.targeting_score > best
-                  @best_scoring = relevant_info
-                end
-              end
-            end
-          end
-
-          if test_case.status == Status::INTERESTING && (
-              @result.nil? || ((sort_key(test_case.choices) <=> sort_key(@result)) == -1)
-          )
-            @result = test_case.choices
-          end
-        end
-
-        # If any test cases have had `target()` called on them, do a simple
-        # hill climbing algorithm to attempt to optimise that target score.
-        def target
-          return if !@result.nil? || @best_scoring.nil?
-
-          # Can we improve the score by changing choices[i] by `step`?
-          adjust = ->(i, step) do
-            fail if @best_scoring.nil?
-
-            score, choices = @best_scoring
-            return false if choices[i] + step < 0 || choices[i].bit_length >= 64
-
-            attempt = choices.dup
-            attempt[i] += step
-            test_case = TestCase.new(
-              prefix: attempt, random: @random, max_size: BUFFER_SIZE
-            )
-            test_function(test_case)
-
-            fail if test_case.status.nil?
-
-            test_case.status >= Status::VALID &&
-              !test_case.targeting_score.nil? &&
-              test_case.targeting_score > score
-          end
-
-          while keep_generating?
-            i = @random.rand(@best_scoring[1].length)
-            sign = 0
-            [1, -1].each do |k|
-              return unless keep_generating?
-
-              if adjust.(i, k)
-                sign = k
-                break
-              end
-            end
-
-            next if sign.zero?
-
-            k = 1
-            k *= 2 while keep_generating? && adjust.(i, sign * k)
-
-            while k.positive?
-              while keep_generating? && adjust.(i, sign * k)
-              end
-              k /= 2
-            end
-          end
-        end
-
-        def run
-          generate
-          target
-          shrink
-        end
-
-        def keep_generating?
-          !@test_is_trivial &&
-            result.nil? &&
-            @valid_test_cases < @max_examples &&
-            # We impose a limit on the maximum number of calls as
-            # well as the maximum number of valid examples. This is
-            # to avoid taking a prohibitively long time on tests which
-            # have hard or impossible to satisfy preconditions.
-            @calls < @max_examples * 10
-        end
-
-        # Run random generation until either we have found an interesting test
-        # case or hit the limit of how many test cases we should evaluate.
-        def generate
-          while keep_generating? && (@best_scoring.nil? || @valid_test_cases < @max_examples / 2)
-            test_function(TestCase.new(prefix: [], random: @random, max_size: BUFFER_SIZE))
-          end
-        end
-
-        # If we have found an interesting example, try shrinking it so that the
-        # choice sequence leading to our best example is shortlex smaller than
-        # the one we originally found. This improves the quality of the generated
-        # test case, as per our paper.
-        #
-        # https://drmaciver.github.io/papers/reduction-via-generation-preview.pdf
-        def shrink
-          # if not self.result:
-          #     return
-          return if @result.nil? || @result.empty?
-
-          # Shrinking will typically try the same choice sequences over and over
-          # again, so we cache the test function in order to not end up
-          # reevaluating it in those cases. This also allows us to catch cases
-          # where we try something that is e.g. a prefix of something we've
-          # previously tried, which is guaranteed not to work.
-          cached = CachedTestFunction.new {|tc| test_function(tc) }
-
-          consider = ->(choices) do
-            return true if choices == @result
-
-            cached.(choices) == Status::INTERESTING
-          end
-
-          fail unless consider.(@result)
-
-          # We are going to perform a number of transformations to the current
-          # result, iterating until none of them make any progress - i.e. until
-          # we make it through an entire iteration of the loop without changing
-          # the result.
-          prev = nil
-          while prev != @result
-            prev = @result
-
-            # A note on weird loop order: We iterate backwards through the choice
-            # sequence rather than forwards, because later bits tend to depend on
-            # earlier bits so it's easier to make changes near the end and
-            # deleting bits at the end may allow us to make changes earlier on
-            # that we we'd have missed.
-            #
-            # Note that we do not restart the loop at the end when we find a
-            # successful shrink. This is because things we've already tried are
-            # less likely to work.
-            #
-            # If this guess is wrong, that's OK, this isn't a correctness
-            # problem, because if we made a successful reduction then we are not
-            # at a fixed point and will restart the loop at the end the next time
-            # round. In some cases this can result in performance issues, but the
-            # end result should still be fine.
-
-            # First try deleting each choice we made in chunks. We try longer
-            # chunks because this allows us to delete whole composite elements:
-            # e.g. deleting an element from a generated list requires us to
-            # delete both the choice of whether to include it and also the
-            # element itself, which may involve more than one choice. Some things
-            # will take more than 8 choices in the sequence. That's too bad, we
-            # may not be able to delete those. In Hypothesis proper we record the
-            # boundaries corresponding to `any` calls so that we can try deleting
-            # those, but that's pretty high overhead and also a bunch of slightly
-            # annoying code that it's not worth porting.
-            #
-            # We could instead do a quadratic amount of work to try all
-            # boundaries, but in general we don't want to do that because even a
-            # shrunk test case can involve a relatively large number of choices.
-            k = 8
-            while k.positive?
-              i = @result.length - k - 1
-              until i.negative?
-                if i >= @result.length
-                  # Can happen if we successfully lowered the value at i - 1
-                  i -= 1
-                  next
-                end
-                attempt = @result[0...i] + (@result[i + k..] || [])
-
-                fail unless attempt.length < @result.length
-
-                unless consider.(attempt)
-                  # This fixes a common problem that occurs
-                  # when you have dependencies on some
-                  # length parameter. e.g. draw a number
-                  # between 0 and 10 and then draw that
-                  # many elements. This can't delete
-                  # everything that occurs that way, but
-                  # it can delete some things and often
-                  # will get us unstuck when nothing else
-                  # does.
-                  if i.positive? && attempt[i - 1].positive?
-                    attempt[i - 1] -= 1
-                    i += 1 if consider.(attempt)
-                  end
-
-                  i -= 1
-                end
-              end
-
-              k /= 2
-            end
-
-            # Attempts to replace some indices in the current result with new
-            # values. Useful for some purely lexicographic reductions that we are
-            # about to perform.
-            replace = ->(values) do
-              fail if @result.nil?
-              attempt = @result.dup
-              values.each do |i, v|
-                # The size of self.result can change during shrinking. If that
-                # happens, stop attempting to make use of these replacements
-                # because some other shrink pass is better to run now.
-                return false if i >= attempt.length
-                attempt[i] = v
-              end
-              consider.(attempt)
-            end
-
-            # Now we try replacing blocks of choices with zeroes. Note that
-            # unlike the above we skip k = 1 because we handle that in the next
-            # step. Often (but not always) a block of all zeroes is the shortlex
-            # smallest value that a region can be.
-            k = 8
-
-            while k > 1
-              i = @result.length - k
-              until i.negative?
-                if replace.((i...i+k).to_h {|i| [i, 0]})
-                  # If we've succeeded then all of [i, i + k] is zero so we
-                  # adjust i so that the next region does not overlap with this
-                  # at all.
-                  i -= k
-                else
-                  # Otherwise we might still be able to zero some of these values
-                  # but not the last one, so we just go back one.
-                  i -= 1
-                end
-              end
-              k /= 2
-            end
-
-            # Now try replacing each choice with a smaller value by doing a
-            # binary search. This will replace n with 0 or n - 1 if possible, but
-            # will also more efficiently replace it with a smaller number than
-            # doing multiple subtractions would.
-            i = @result.length - 1
-            until i.negative?
-              # Attempt to replace
-              bin_search_down(0, @result[i]) {|v| replace.({i => v}) }
-              i -= 1
-            end
-
-            # NB from here on this is just showing off cool shrinker tricks and
-            # you probably don't need to worry about it and can skip these bits
-            # unless they're easy and you want bragging rights for how much
-            # better you are at shrinking than the local QuickCheck equivalent.
-
-            # Try sorting out of order ranges of choices, as `sort(x) <= x`, so
-            # this is always a lexicographic reduction.
-            k = 8
-            # while k > 1:
-            while k > 1
-              (@result.length - k - 1).downto(0).each do |i|
-                consider.(@result[0...i] + @result[i...i+k].sort + @result[i+k..])
-              end
-                k /= 2
-            end
-
-            # Try adjusting nearby pairs of integers by redistributing value
-            # between them. This is useful for tests that depend on the sum of
-            # some generated values.
-            [2, 1].each do |k|
-              (@result.length - k - 1).downto(0).each do |i|
-                j = i + k
-                # This check is necessary because the previous changes might have
-                # shrunk the size of result, but also it's tedious to write tests
-                # for this so I didn't.
-                if j < @result.length
-                  # Try swapping out of order pairs
-                  if @result[i] > @result[j]
-                    replace.({j => @result[i], i => @result[j]})
-                  end
-                  # j could be out of range if the previous swap succeeded.
-                  if j < @result.length && @result[i].positive?
-                    prev_i = @result[i]
-                    prev_j = @result[j]
-                    bin_search_down(0, prev_i) {|v|
-                      replace.({i => v, j => prev_j + (prev_i - v)})
-                    }
-                  end
-                end
-              end
-            end
-          end
-        end
-
-        private
-
-        # Returns a key that can be used for the shrinking order of test cases.
-        def sort_key(choices) = [choices.length, choices]
-
-        # Returns n in [lo, hi] such that f(n) is True, where it is assumed and
-        # will not be checked that f(hi) is True.
-        #
-        # Will return `lo` if `f(lo)` is True, otherwise the only guarantee that is
-        # made is that `f(n - 1)` is False and `f(n)` is True. In particular this
-        # does *not* guarantee to find the smallest value, only a locally minimal
-        # one.
-        def bin_search_down(low, high, &f)
-          return low if f.(low)
-          while low + 1 < high
-            mid = low + (high - low) / 2
-            if f.(mid)
-              high = mid
-            else
-              low = mid
-            end
-          end
-          high
-        end
-      end
-
-      class DirectoryDb
-        def initialize(dir)
-          @dir = dir
-          Dir.mkdir(@dir)
-        rescue SystemCallError => e
-          raise unless e.errno == Errno::EEXIST::Errno
-        end
-
-        def [](key)
-          f = file(key)
-          return nil unless File.exist?(f)
-
-          File.read(f)
-        end
-
-        def []=(key, value)
-          File.write(file(key), value)
-        end
-
-        private
-
-        def file(key)
-          File.join(@dir, Digest::SHA1.hexdigest(key)[0...10])
-        end
-      end
-
-      class Error< StandardError; end
-
-      # Attempted to make choices on a test case that has been completed.
-      class Frozen < Error; end
-
-      # Raised when a test should stop executing early.
-      class StopTest < Error; end
-
-      # Raised when a test has no valid examples.
-      class Unsatisfiable < Error; end
-
-      class Status < Struct.new(:value)
-        # Test case didn't have enough data to complete
-        OVERRUN = self.new(0)
-
-        # Test case contained something that prevented completion
-        INVALID = self.new(1)
-
-        # Test case completed just fine but was boring
-        VALID = self.new(2)
-
-        # Test case completed and was interesting
-        INTERESTING = self.new(3)
-
-        include Comparable
-
-        def <=>(other)
-          value <=> other.value
-        end
-      end
     end
   end
 end
diff --git a/lib/minitest/thesis/directory_db.rb b/lib/minitest/thesis/directory_db.rb
new file mode 100644
index 0000000..bde6fa5
--- /dev/null
+++ b/lib/minitest/thesis/directory_db.rb
@@ -0,0 +1,27 @@
+module Minitest::Thesis
+  class DirectoryDb
+    def initialize(dir)
+      @dir = dir
+      Dir.mkdir(@dir)
+    rescue SystemCallError => e
+      raise unless e.errno == Errno::EEXIST::Errno
+    end
+
+    def [](key)
+      f = file(key)
+      return nil unless File.exist?(f)
+
+      File.read(f)
+    end
+
+    def []=(key, value)
+      File.write(file(key), value)
+    end
+
+    private
+
+    def file(key)
+      File.join(@dir, Digest::SHA1.hexdigest(key)[0...10])
+    end
+  end
+end
diff --git a/lib/minitest/thesis/error.rb b/lib/minitest/thesis/error.rb
new file mode 100644
index 0000000..f51ac76
--- /dev/null
+++ b/lib/minitest/thesis/error.rb
@@ -0,0 +1,12 @@
+module Minitest::Thesis
+  Error = Class.new(StandardError)
+
+  # Attempted to make choices on a test case that has been completed.
+  Frozen = Class.new(Error)
+
+  # Raised when a test should stop executing early.
+  StopTest = Class.new(Error)
+
+  # Raised when a test has no valid examples.
+  Unsatisfiable = Class.new(Error)
+end
diff --git a/lib/minitest/thesis/possibility.rb b/lib/minitest/thesis/possibility.rb
new file mode 100644
index 0000000..8f24e81
--- /dev/null
+++ b/lib/minitest/thesis/possibility.rb
@@ -0,0 +1,39 @@
+module Minitest::Thesis
+  # Represents some range of values that might be used in a test, that can be
+  # requested from a `TestCase`. Pass one of these to TestCase.any to get a
+  # concrete value.
+  class Possibility
+    attr_reader :produce, :name
+
+    def initialize(name = "TODO", &produce)
+      @name = name
+      @produce = produce
+    end
+
+    def inspect = name
+    def to_s = name
+
+    # "Returns a `Possibility` where values come from applying `f` to some possible value for `self`."
+    def map(&f)
+      self.class.new("#{name}.map(TODO)") {|tc| f.call(tc.any(self)) }
+    end
+
+    # Returns a `Possibility` where values come from applying `f` (which
+    # should return a new `Possibility` to some possible value for `self`
+    # then returning a possible value from that.
+    def bind(&f)
+      self.class.new("#{name}.bind(TODO)") {|tc| tc.any(f.(tc.any(self))) }
+    end
+
+    # Returns a `Possibility` whose values are any possible value of `self`
+    # for which `f` returns True.
+    def satisfying(&f)
+      self.class.new("#{name}.select(TODO)") {|test_case|
+        3.times.first {
+          candidate = test_case.any(self)
+          candidate if f.(candidate)
+        } || test_case.reject
+      }
+    end
+  end
+end
diff --git a/lib/minitest/thesis/status.rb b/lib/minitest/thesis/status.rb
new file mode 100644
index 0000000..ca8572b
--- /dev/null
+++ b/lib/minitest/thesis/status.rb
@@ -0,0 +1,21 @@
+module Minitest::Thesis
+  class Status < Struct.new(:value)
+    # Test case didn't have enough data to complete
+    OVERRUN = self.new(0)
+
+    # Test case contained something that prevented completion
+    INVALID = self.new(1)
+
+    # Test case completed just fine but was boring
+    VALID = self.new(2)
+
+    # Test case completed and was interesting
+    INTERESTING = self.new(3)
+
+    include Comparable
+
+    def <=>(other)
+      value <=> other.value
+    end
+  end
+end
diff --git a/lib/minitest/thesis/test_case.rb b/lib/minitest/thesis/test_case.rb
new file mode 100644
index 0000000..ebd8ad9
--- /dev/null
+++ b/lib/minitest/thesis/test_case.rb
@@ -0,0 +1,121 @@
+require_relative "error"
+require_relative "status"
+
+module Minitest::Thesis
+  # Represents a single generated test case, which consists of an underlying
+  # set of choices that produce possibilities.
+  class TestCase
+
+    # Returns a test case that makes this series of choices.
+    def self.for_choices(choices, print_results: false)
+      self.new(prefix: choices, random: nil, max_size: choices.length, print_results:)
+    end
+
+    attr_accessor :status
+    attr_reader :choices, :targeting_score
+
+    def initialize(prefix:, random:, max_size: Float::INFINITY, print_results: false)
+      @prefix, @random, @max_size, @print_results = prefix, random, max_size, print_results
+      @choices = []
+      @status = nil
+      @depth = 0
+      @targeting_score = nil
+    end
+
+    # Returns a number in the range [0, n]
+    def choice(n)
+      result = make_choice(n) { @random.rand(n) }
+
+      puts "choice(#{n}): #{result}" if should_print?
+
+      result
+    end
+
+    # Return True with probability `p`.
+    def weighted(p)
+      result = if    p <= 0 then forced_choice(0)
+               elsif p >= 1 then forced_choice(1)
+               else              make_choice(1) { (@random.rand <= p) ? 1 : 0 }
+               end
+
+      puts "weighted(#{p}): #{result}" if should_print?
+
+      result
+    end
+
+    # Inserts a fake choice into the choice sequence, as if some call to
+    # choice() had returned `n`. You almost never need this, but sometimes it
+    # can be a useful hint to the shrinker.
+    def forced_choice(n)
+      raise RangeError.new("Invalid choice #{n}") if n.bit_length > 64 || n.negative?
+      raise Frozen unless @status.nil?
+
+      mark_status(Status::OVERRUN) if @choices.length >= @max_size
+
+      choices << n
+      n
+    end
+
+    # Mark this test case as invalid.
+    def reject = mark_status(Status::INVALID)
+
+    # If this precondition is not met, abort the test and mark this test case as invalid.
+    def assume(precondition)
+      return if precondition
+      reject
+    end
+
+    # Set a score to maximize. Multiple calls to this function will override previous ones.
+    #
+    # The name and idea come from Löscher, Andreas, and Konstantinos Sagonas.
+    # "Targeted property-based testing." ISSTA. 2017, but the implementation
+    # is based on that found in Hypothesis, which is not that similar to
+    # anything described in the paper.
+    def target(score) = @targeting_score = score
+
+    # Return a possible value from `possibility`.
+    def any(possibility)
+      begin
+        @depth += 1
+        result = possibility.produce.(self)
+      ensure
+        @depth -= 1
+      end
+
+      puts "any(#{possibility}): #{result}" if should_print?
+
+      result
+    end
+
+    # Set the status and raise StopTest.
+    def mark_status(status)
+      raise Frozen unless self.status.nil?
+
+      @status = status
+      raise StopTest
+    end
+
+    private
+
+    def should_print? = @print_results && @depth.zero?
+
+    # Make a choice in [0, n], by calling rnd_method if randomness is needed.
+    def make_choice(n, &rnd_method)
+      raise RangeError.new("Invalid choice #{n}") if n.bit_length > 64 || n.negative?
+      raise Frozen unless @status.nil?
+
+      mark_status(Status::OVERRUN) if @choices.length >= @max_size
+
+      result = if @choices.length < @prefix.length
+                 @prefix[@choices.length]
+               else
+                 rnd_method.()
+               end
+      @choices << result
+
+      mark_status(Status::INVALID) if result > n
+
+      result
+    end
+  end
+end
diff --git a/lib/minitest/thesis/testing_state.rb b/lib/minitest/thesis/testing_state.rb
new file mode 100644
index 0000000..d36e538
--- /dev/null
+++ b/lib/minitest/thesis/testing_state.rb
@@ -0,0 +1,418 @@
+module Minitest::Thesis
+  # We cap the maximum amount of entropy a test case can use.
+  # This prevents cases where the generated test case size explodes
+  # by effectively rejection
+  BUFFER_SIZE = 8 * 1024
+
+  class TestingState
+    attr_reader :result, :valid_test_cases, :calls
+
+    def initialize(random:, test_function:, max_examples:)
+      @random, @_test_function, @max_examples = random, test_function, max_examples
+      @valid_test_cases = 0
+      @calls = 0
+      @test_is_trivial = false
+    end
+
+    def test_function(test_case)
+      begin
+        @_test_function.(test_case)
+      rescue StopTest
+      end
+
+      if test_case.status.nil?
+        test_case.status = Status::VALID
+      end
+
+      @calls += 1
+
+      if test_case.status >= Status::INVALID && test_case.choices.length.zero?
+        @test_is_trivial = true
+      end
+
+      if test_case.status >= Status::VALID
+        @valid_test_cases += 1
+
+        unless test_case.targeting_score.nil?
+          relevant_info = [test_case.targeting_score, test_case.choices]
+          if @best_scoring.nil?
+            @best_scoring = relevant_info
+          else
+            best, _ = @best_scoring
+            if test_case.targeting_score > best
+              @best_scoring = relevant_info
+            end
+          end
+        end
+      end
+
+      if test_case.status == Status::INTERESTING && (
+          @result.nil? || ((sort_key(test_case.choices) <=> sort_key(@result)) == -1)
+      )
+        @result = test_case.choices
+      end
+    end
+
+    # If any test cases have had `target()` called on them, do a simple
+    # hill climbing algorithm to attempt to optimise that target score.
+    def target
+      return if !@result.nil? || @best_scoring.nil?
+
+      # Can we improve the score by changing choices[i] by `step`?
+      adjust = ->(i, step) do
+        fail if @best_scoring.nil?
+
+        score, choices = @best_scoring
+        return false if choices[i] + step < 0 || choices[i].bit_length >= 64
+
+        attempt = choices.dup
+        attempt[i] += step
+        test_case = TestCase.new(
+          prefix: attempt, random: @random, max_size: BUFFER_SIZE
+        )
+        test_function(test_case)
+
+        fail if test_case.status.nil?
+
+        test_case.status >= Status::VALID &&
+          !test_case.targeting_score.nil? &&
+          test_case.targeting_score > score
+      end
+
+      while keep_generating?
+        i = @random.rand(@best_scoring[1].length)
+        sign = 0
+        [1, -1].each do |k|
+          return unless keep_generating?
+
+          if adjust.(i, k)
+            sign = k
+            break
+          end
+        end
+
+        next if sign.zero?
+
+        k = 1
+        k *= 2 while keep_generating? && adjust.(i, sign * k)
+
+        while k.positive?
+          while keep_generating? && adjust.(i, sign * k)
+          end
+          k /= 2
+        end
+      end
+    end
+
+    def run
+      generate
+      target
+      shrink
+    end
+
+    def keep_generating?
+      !@test_is_trivial &&
+        result.nil? &&
+        @valid_test_cases < @max_examples &&
+        # We impose a limit on the maximum number of calls as
+        # well as the maximum number of valid examples. This is
+        # to avoid taking a prohibitively long time on tests which
+        # have hard or impossible to satisfy preconditions.
+        @calls < @max_examples * 10
+    end
+
+    # Run random generation until either we have found an interesting test
+    # case or hit the limit of how many test cases we should evaluate.
+    def generate
+      while keep_generating? && (@best_scoring.nil? || @valid_test_cases < @max_examples / 2)
+        test_function(TestCase.new(prefix: [], random: @random, max_size: BUFFER_SIZE))
+      end
+    end
+
+    # If we have found an interesting example, try shrinking it so that the
+    # choice sequence leading to our best example is shortlex smaller than
+    # the one we originally found. This improves the quality of the generated
+    # test case, as per our paper.
+    #
+    # https://drmaciver.github.io/papers/reduction-via-generation-preview.pdf
+    def shrink
+      # if not self.result:
+      #     return
+      return if @result.nil? || @result.empty?
+
+      # Shrinking will typically try the same choice sequences over and over
+      # again, so we cache the test function in order to not end up
+      # reevaluating it in those cases. This also allows us to catch cases
+      # where we try something that is e.g. a prefix of something we've
+      # previously tried, which is guaranteed not to work.
+      cached = CachedTestFunction.new {|tc| test_function(tc) }
+
+      consider = ->(choices) do
+        return true if choices == @result
+
+        cached.(choices) == Status::INTERESTING
+      end
+
+      fail unless consider.(@result)
+
+      # We are going to perform a number of transformations to the current
+      # result, iterating until none of them make any progress - i.e. until
+      # we make it through an entire iteration of the loop without changing
+      # the result.
+      prev = nil
+      while prev != @result
+        prev = @result
+
+        # A note on weird loop order: We iterate backwards through the choice
+        # sequence rather than forwards, because later bits tend to depend on
+        # earlier bits so it's easier to make changes near the end and
+        # deleting bits at the end may allow us to make changes earlier on
+        # that we we'd have missed.
+        #
+        # Note that we do not restart the loop at the end when we find a
+        # successful shrink. This is because things we've already tried are
+        # less likely to work.
+        #
+        # If this guess is wrong, that's OK, this isn't a correctness
+        # problem, because if we made a successful reduction then we are not
+        # at a fixed point and will restart the loop at the end the next time
+        # round. In some cases this can result in performance issues, but the
+        # end result should still be fine.
+
+        # First try deleting each choice we made in chunks. We try longer
+        # chunks because this allows us to delete whole composite elements:
+        # e.g. deleting an element from a generated list requires us to
+        # delete both the choice of whether to include it and also the
+        # element itself, which may involve more than one choice. Some things
+        # will take more than 8 choices in the sequence. That's too bad, we
+        # may not be able to delete those. In Hypothesis proper we record the
+        # boundaries corresponding to `any` calls so that we can try deleting
+        # those, but that's pretty high overhead and also a bunch of slightly
+        # annoying code that it's not worth porting.
+        #
+        # We could instead do a quadratic amount of work to try all
+        # boundaries, but in general we don't want to do that because even a
+        # shrunk test case can involve a relatively large number of choices.
+        k = 8
+        while k.positive?
+          i = @result.length - k - 1
+          until i.negative?
+            if i >= @result.length
+              # Can happen if we successfully lowered the value at i - 1
+              i -= 1
+              next
+            end
+            attempt = @result[0...i] + (@result[i + k..] || [])
+
+            fail unless attempt.length < @result.length
+
+            unless consider.(attempt)
+              # This fixes a common problem that occurs
+              # when you have dependencies on some
+              # length parameter. e.g. draw a number
+              # between 0 and 10 and then draw that
+              # many elements. This can't delete
+              # everything that occurs that way, but
+              # it can delete some things and often
+              # will get us unstuck when nothing else
+              # does.
+              if i.positive? && attempt[i - 1].positive?
+                attempt[i - 1] -= 1
+                i += 1 if consider.(attempt)
+              end
+
+              i -= 1
+            end
+          end
+
+          k /= 2
+        end
+
+        # Attempts to replace some indices in the current result with new
+        # values. Useful for some purely lexicographic reductions that we are
+        # about to perform.
+        replace = ->(values) do
+          fail if @result.nil?
+          attempt = @result.dup
+          values.each do |i, v|
+            # The size of self.result can change during shrinking. If that
+            # happens, stop attempting to make use of these replacements
+            # because some other shrink pass is better to run now.
+            return false if i >= attempt.length
+            attempt[i] = v
+          end
+          consider.(attempt)
+        end
+
+        # Now we try replacing blocks of choices with zeroes. Note that
+        # unlike the above we skip k = 1 because we handle that in the next
+        # step. Often (but not always) a block of all zeroes is the shortlex
+        # smallest value that a region can be.
+        k = 8
+
+        while k > 1
+          i = @result.length - k
+          until i.negative?
+            if replace.((i...i+k).to_h {|i| [i, 0]})
+              # If we've succeeded then all of [i, i + k] is zero so we
+              # adjust i so that the next region does not overlap with this
+              # at all.
+              i -= k
+            else
+              # Otherwise we might still be able to zero some of these values
+              # but not the last one, so we just go back one.
+              i -= 1
+            end
+          end
+          k /= 2
+        end
+
+        # Now try replacing each choice with a smaller value by doing a
+        # binary search. This will replace n with 0 or n - 1 if possible, but
+        # will also more efficiently replace it with a smaller number than
+        # doing multiple subtractions would.
+        i = @result.length - 1
+        until i.negative?
+          # Attempt to replace
+          bin_search_down(0, @result[i]) {|v| replace.({i => v}) }
+          i -= 1
+        end
+
+        # NB from here on this is just showing off cool shrinker tricks and
+        # you probably don't need to worry about it and can skip these bits
+        # unless they're easy and you want bragging rights for how much
+        # better you are at shrinking than the local QuickCheck equivalent.
+
+        # Try sorting out of order ranges of choices, as `sort(x) <= x`, so
+        # this is always a lexicographic reduction.
+        k = 8
+        # while k > 1:
+        while k > 1
+          (@result.length - k - 1).downto(0).each do |i|
+            consider.(@result[0...i] + @result[i...i+k].sort + @result[i+k..])
+          end
+            k /= 2
+        end
+
+        # Try adjusting nearby pairs of integers by redistributing value
+        # between them. This is useful for tests that depend on the sum of
+        # some generated values.
+        [2, 1].each do |k|
+          (@result.length - k - 1).downto(0).each do |i|
+            j = i + k
+            # This check is necessary because the previous changes might have
+            # shrunk the size of result, but also it's tedious to write tests
+            # for this so I didn't.
+            if j < @result.length
+              # Try swapping out of order pairs
+              if @result[i] > @result[j]
+                replace.({j => @result[i], i => @result[j]})
+              end
+              # j could be out of range if the previous swap succeeded.
+              if j < @result.length && @result[i].positive?
+                prev_i = @result[i]
+                prev_j = @result[j]
+                bin_search_down(0, prev_i) {|v|
+                  replace.({i => v, j => prev_j + (prev_i - v)})
+                }
+              end
+            end
+          end
+        end
+      end
+    end
+
+    private
+
+    # Returns a key that can be used for the shrinking order of test cases.
+    def sort_key(choices) = [choices.length, choices]
+
+    # Returns n in [lo, hi] such that f(n) is True, where it is assumed and
+    # will not be checked that f(hi) is True.
+    #
+    # Will return `lo` if `f(lo)` is True, otherwise the only guarantee that is
+    # made is that `f(n - 1)` is False and `f(n)` is True. In particular this
+    # does *not* guarantee to find the smallest value, only a locally minimal
+    # one.
+    def bin_search_down(low, high, &f)
+      return low if f.(low)
+      while low + 1 < high
+        mid = low + (high - low) / 2
+        if f.(mid)
+          high = mid
+        else
+          low = mid
+        end
+      end
+      high
+    end
+  end
+
+  # Returns a cached version of a function that maps a choice sequence to the
+  # status of calling a test function on a test case populated with it. Is
+  # able to take advantage of the structure of the test function to predict
+  # the result even if exact sequence of choices has not been seen
+  # previously.
+  #
+  # You can safely omit implementing this at the cost of somewhat increased
+  # shrinking time.
+  class CachedTestFunction
+    def initialize(&test_function)
+      @test_function = test_function
+
+      # Tree nodes are either a point at which a choice occurs
+      # in which case they map the result of the choice to the
+      # tree node we are in after, or a Status object indicating
+      # mark_status was called at this point and all future
+      # choices are irrelevant.
+      #
+      # Note that a better implementation of this would use
+      # a Patricia trie, which implements long non-branching
+      # paths as an array inline. For simplicity we don't
+      # do that here.
+      @tree = {}
+    end
+
+    def call(choices)
+      node = @tree
+      begin
+        choices.each do |c|
+          node = node.fetch(c)
+          # mark_status was called, thus future choices
+          # will be ignored.
+          if node.is_a?(Status)
+            fail if node == Status::OVERRUN
+            return node
+          end
+        end
+        # If we never entered an unknown region of the tree
+        # or hit a Status value, then we know that another
+        # choice will be made next and the result will overrun.
+        return Status::OVERRUN
+      rescue KeyError
+      end
+
+      # We now have to actually call the test function to find out what
+      # happens.
+      test_case = TestCase.for_choices(choices)
+      @test_function.(test_case)
+      fail if test_case.status.nil?
+
+      # We enter the choices made in a tree.
+      node = @tree
+      *rest, last = test_case.choices
+      rest.each do |c|
+        node = if node.has_key?(c)
+                 node[c]
+               else
+                 node[c] = {}
+               end
+      end
+      unless last.nil?
+        node[last] = test_case.status == Status::OVERRUN ? {} : test_case.status
+      end
+
+      test_case.status
+    end
+  end
+end
diff --git a/lib/minitest/thesis/version.rb b/lib/minitest/thesis/version.rb
new file mode 100644
index 0000000..4ac2525
--- /dev/null
+++ b/lib/minitest/thesis/version.rb
@@ -0,0 +1,3 @@
+module Minitest::Thesis
+  VERSION = "0.1.0"
+end
diff --git a/test/minitest/thesis_test.rb b/test/minitest/thesis_test.rb
index 9e2e36f..e0c1ade 100644
--- a/test/minitest/thesis_test.rb
+++ b/test/minitest/thesis_test.rb
@@ -1,442 +1,469 @@
 require "test_helper"
 
-class Minitest::ThesisTest < Minitest::Thesis::Test
-  class Failure < StandardError; end
-
-  def test_finds_small_list
-    (0...10).each do |seed|
-      out, _ = capture_io do
-        assert_raises(Minitest::Assertion) do
-          run_test("finds_small_list", database: {}, random: Random.new(seed)) do |test_case|
-            ls = test_case.any(lists(integers(0, 10_000)))
-            assert ls.sum <= 1_000
+module Minitest::Thesis
+  class ThesisTest < Minitest::Thesis::Test
+    class Failure < StandardError; end
+
+    def test_finds_small_list
+      (0...10).each do |seed|
+        out, _ = capture_io do
+          assert_raises(Minitest::Assertion) do
+            run_test("finds_small_list", database: {}, random: Random.new(seed)) do |test_case|
+              ls = test_case.any(lists(integers(0, 10_000)))
+              assert ls.sum <= 1_000
+            end
           end
         end
+
+        assert_equal <<~OUT, out
+          any(lists(integers(0, 10000))): [1001]
+        OUT
       end
+    end
 
-      assert_equal <<~OUT, out
-        any(lists(integers(0, 10000))): [1001]
-      OUT
+    # Minithesis can't really handle shrinking arbitrary monadic bind, but length
+    # parameters are a common case of monadic bind that it has a little bit of
+    # special casing for. This test ensures that that special casing works.
+    #
+    # The problem is that if you generate a list by drawing a length and then
+    # drawing that many elements, you can end up with something like ``[1001, 0,
+    # 0]`` then deleting those zeroes in the middle is a pain. minithesis will
+    # solve this by first sorting those elements, so that we have ``[0, 0,
+    # 1001]``, and then lowering the length by two, turning it into ``[1001]`` as
+    # desired.
+    def test_finds_small_list_even_with_bad_lists
+      bad_list = Possibility.new("bad_list") {|tc|
+        n = tc.choice(10)
+        Array.new(n) { tc.choice(10_000) }
+      }
+
+      (0...10).each do |seed|
+        out, _ = capture_io do
+          assert_raises(Minitest::Assertion) do
+            run_test("finds_small_list_even_with_bad_lists", database: {}, random: Random.new(seed)) do |test_case|
+              ls = test_case.any(bad_list)
+              assert ls.sum <= 1_000
+            end
+          end
+        end
+
+        assert_equal <<~OUT, out
+          any(bad_list): [1001]
+        OUT
+      end
     end
-  end
 
-  # Minithesis can't really handle shrinking arbitrary monadic bind, but length
-  # parameters are a common case of monadic bind that it has a little bit of
-  # special casing for. This test ensures that that special casing works.
-  #
-  # The problem is that if you generate a list by drawing a length and then
-  # drawing that many elements, you can end up with something like ``[1001, 0,
-  # 0]`` then deleting those zeroes in the middle is a pain. minithesis will
-  # solve this by first sorting those elements, so that we have ``[0, 0,
-  # 1001]``, and then lowering the length by two, turning it into ``[1001]`` as
-  # desired.
-  def test_finds_small_list_even_with_bad_lists
-    bad_list = Possibility.new("bad_list") {|tc|
-      n = tc.choice(10)
-      Array.new(n) { tc.choice(10_000) }
-    }
-
-    (0...10).each do |seed|
+    def test_reduces_additive_pairs
       out, _ = capture_io do
         assert_raises(Minitest::Assertion) do
-          run_test("finds_small_list_even_with_bad_lists", database: {}, random: Random.new(seed)) do |test_case|
-            ls = test_case.any(bad_list)
-            assert ls.sum <= 1_000
+          run_test("reduces_additive_pairs", database: {}, max_examples: 10_000) do |test_case|
+            m = test_case.choice(1000)
+            n = test_case.choice(1000)
+            assert m + n <= 1000
           end
         end
       end
 
       assert_equal <<~OUT, out
-        any(bad_list): [1001]
+        choice(1000): 1
+        choice(1000): 1000
       OUT
     end
-  end
-
-  def test_reduces_additive_pairs
-    out, _ = capture_io do
-      assert_raises(Minitest::Assertion) do
-        run_test("reduces_additive_pairs", database: {}, max_examples: 10_000) do |test_case|
-          m = test_case.choice(1000)
-          n = test_case.choice(1000)
-          assert m + n <= 1000
-        end
-      end
-    end
-
-    assert_equal <<~OUT, out
-      choice(1000): 1
-      choice(1000): 1000
-    OUT
-  end
 
-  def test_reuses_results_from_the_database
-    Dir.mktmpdir do |tmpdir|
-      db = DirectoryDb.new(tmpdir)
-      count = 0
+    def test_reuses_results_from_the_database
+      Dir.mktmpdir do |tmpdir|
+        db = DirectoryDb.new(tmpdir)
+        count = 0
 
-      run = -> {
-        assert_raises(Minitest::Assertion) do
-          run_test("reuses_results_from_the_database", database: db, quiet: true) do |test_case|
-            count += 1
-            assert test_case.choice(10_000) < 10
+        run = -> {
+          assert_raises(Minitest::Assertion) do
+            run_test("reuses_results_from_the_database", database: db, quiet: true) do |test_case|
+              count += 1
+              assert test_case.choice(10_000) < 10
+            end
           end
-        end
-      }
-
-      run.()
+        }
 
-      assert_equal 1, Dir.children(tmpdir).length
-      prev_count = count
+        run.()
 
-      run.()
+        assert_equal 1, Dir.children(tmpdir).length
+        prev_count = count
 
-      assert_equal 1, Dir.children(tmpdir).length
-      assert_equal prev_count + 2, count
-    end
-  end
+        run.()
 
-  def test_test_cases_satisfy_preconditions
-    run_test("test_cases_satisfy_preconditions", database: {}) do |test_case|
-      n = test_case.choice(10)
-      test_case.assume(n != 0)
-      refute_equal 0, n
+        assert_equal 1, Dir.children(tmpdir).length
+        assert_equal prev_count + 2, count
+      end
     end
-  end
 
-  def test_error_on_too_strict_precondition
-    assert_raises(Unsatisfiable) do
-      run_test("error_on_too_strict_precondition", database: {}) do |test_case|
+    def test_test_cases_satisfy_preconditions
+      run_test("test_cases_satisfy_preconditions", database: {}) do |test_case|
         n = test_case.choice(10)
-        test_case.reject
+        test_case.assume(n != 0)
+        refute_equal 0, n
       end
     end
-  end
-
-  def test_error_on_unbounded_test_function
-    orig_buffer_size = BUFFER_SIZE
-    suppress_warnings do
-      Minitest::Thesis.const_set(:BUFFER_SIZE, 10)
-    end
 
-    assert_raises(Unsatisfiable) do
-      run_test("error_on_unbounded_test_function", database: {}, max_examples: 5) do |test_case|
-        loop do
+    def test_error_on_too_strict_precondition
+      assert_raises(Unsatisfiable) do
+        run_test("error_on_too_strict_precondition", database: {}) do |test_case|
           test_case.choice(10)
+          test_case.reject
         end
       end
     end
-  ensure
-    suppress_warnings do
-      Minitest::Thesis.const_set(:BUFFER_SIZE, orig_buffer_size)
-    end
-  end
 
-  def test_function_cache
-    tf = ->(tc) do
-      tc.mark_status(Status::INTERESTING) if tc.choice(1_000) >= 200
-      tc.reject if tc.choice(1).zero?
-    end
-
-    state = TestingState.new(random: Random.new(0), test_function: tf, max_examples: 100)
-    cache = CachedTestFunction.new {|tc| state.test_function(tc) }
+    def test_error_on_unbounded_test_function
+      orig_buffer_size = BUFFER_SIZE
+      suppress_warnings do
+        Minitest::Thesis.const_set(:BUFFER_SIZE, 10)
+      end
 
-    assert_equal Status::VALID, cache.([1, 1])
-    assert_equal Status::OVERRUN, cache.([1])
-    assert_equal Status::INTERESTING, cache.([1_000])
-    assert_equal Status::INTERESTING, cache.([1_000])
-    assert_equal Status::INTERESTING, cache.([1_000, 1])
+      assert_raises(Unsatisfiable) do
+        run_test("error_on_unbounded_test_function", database: {}, max_examples: 5) do |test_case|
+          loop do
+            test_case.choice(10)
+          end
+        end
+      end
+    ensure
+      suppress_warnings do
+        Minitest::Thesis.const_set(:BUFFER_SIZE, orig_buffer_size)
+      end
+    end
 
-    assert_equal 2, state.calls
-  end
+    def test_function_cache
+      tf = ->(tc) do
+        tc.mark_status(Status::INTERESTING) if tc.choice(1_000) >= 200
+        tc.reject if tc.choice(1).zero?
+      end
 
-  # Targeting has a number of places it checks for whether we've exceeded the
-  # generation limits. This makes sure we've checked them all.
-  def test_max_examples_is_not_exceeded
-    (1...100).each do |max_examples|
-      calls = 0
+      state = TestingState.new(random: Random.new(0), test_function: tf, max_examples: 100)
+      cache = CachedTestFunction.new {|tc| state.test_function(tc) }
 
-      run_test(
-        "max_examples_is_not_exceeded",
-        database: {},
-        random: Random.new(0),
-        max_examples:,
-      ) do |tc|
-        m = 10000
-        n = tc.choice(m)
-        calls += 1
-        tc.target(n * (m - n))
-      end
+      assert_equal Status::VALID, cache.([1, 1])
+      assert_equal Status::OVERRUN, cache.([1])
+      assert_equal Status::INTERESTING, cache.([1_000])
+      assert_equal Status::INTERESTING, cache.([1_000])
+      assert_equal Status::INTERESTING, cache.([1_000, 1])
 
-      assert_equal max_examples, calls
+      assert_equal 2, state.calls
     end
-  end
 
+    # Targeting has a number of places it checks for whether we've exceeded the
+    # generation limits. This makes sure we've checked them all.
+    def test_max_examples_is_not_exceeded
+      (1...100).each do |max_examples|
+        calls = 0
 
-  # Targeting has a number of places it checks for whether we've exceeded the
-  # generation limits. This makes sure we've checked them all.
-  def test_finds_a_local_maximum
-    (0...100).each do |seed|
-      assert_raises(Minitest::Assertion) do
         run_test(
-          "finds_a_local_maximum",
+          "max_examples_is_not_exceeded",
           database: {},
-          random: Random.new(seed),
-          max_examples: 200,
-          quiet: true
+          random: Random.new(0),
+          max_examples:,
         ) do |tc|
-          m = tc.choice(1000)
-          n = tc.choice(1000)
-          score = -((m - 500) ** 2 + (n - 500) ** 2)
-          tc.target(score)
-          assert m != 500 || n != 500
+          m = 10000
+          n = tc.choice(m)
+          calls += 1
+          tc.target(n * (m - n))
         end
+
+        assert_equal max_examples, calls
       end
     end
-  end
 
-  def test_can_target_a_score_upwards_to_interesting
-    out, _ = capture_io do
-      assert_raises(Minitest::Assertion) do
-        run_test("can_target_a_score_upwards_to_interesting", database: {}, max_examples: 1000) do |test_case|
-          n = test_case.choice(1000)
-          m = test_case.choice(1000)
-          score = n + m
-          test_case.target(score)
-          assert score < 2000
+
+    # Targeting has a number of places it checks for whether we've exceeded the
+    # generation limits. This makes sure we've checked them all.
+    def test_finds_a_local_maximum
+      (0...100).each do |seed|
+        assert_raises(Minitest::Assertion) do
+          run_test(
+            "finds_a_local_maximum",
+            database: {},
+            random: Random.new(seed),
+            max_examples: 200,
+            quiet: true
+          ) do |tc|
+            m = tc.choice(1000)
+            n = tc.choice(1000)
+            score = -((m - 500) ** 2 + (n - 500) ** 2)
+            tc.target(score)
+            assert m != 500 || n != 500
+          end
         end
       end
     end
 
-    assert_equal <<~OUT, out
-      choice(1000): 1000
-      choice(1000): 1000
-    OUT
-  end
-
-  def test_can_target_a_score_upwards_without_failing
-    max_score = 0
+    def test_can_target_a_score_upwards_to_interesting
+      out, _ = capture_io do
+        assert_raises(Minitest::Assertion) do
+          run_test("can_target_a_score_upwards_to_interesting", database: {}, max_examples: 1000) do |test_case|
+            n = test_case.choice(1000)
+            m = test_case.choice(1000)
+            score = n + m
+            test_case.target(score)
+            assert score < 2000
+          end
+        end
+      end
 
-    run_test("can_target_a_score_upwards_without_failing", database: {}, max_examples: 1000) do |test_case|
-      n = test_case.choice(1000)
-      m = test_case.choice(1000)
-      score = n + m
-      test_case.target(score)
-      max_score = [score, max_score].max
+      assert_equal <<~OUT, out
+        choice(1000): 1000
+        choice(1000): 1000
+      OUT
     end
 
-    assert_equal 2000, max_score
-  end
+    def test_can_target_a_score_upwards_without_failing
+      max_score = 0
 
-  def test_targeting_when_most_do_not_benefit
-    big = 10_000
-
-    out, _ = capture_io do
-      assert_raises(Minitest::Assertion) do
-        run_test("targeting_when_most_do_not_benefit", database: {}, max_examples: 1000) do |test_case|
-          test_case.choice(1000)
-          test_case.choice(1000)
-          score = test_case.choice(big)
-          test_case.target(score)
-          assert score < big
-        end
+      run_test("can_target_a_score_upwards_without_failing", database: {}, max_examples: 1000) do |test_case|
+        n = test_case.choice(1000)
+        m = test_case.choice(1000)
+        score = n + m
+        test_case.target(score)
+        max_score = [score, max_score].max
       end
+
+      assert_equal 2000, max_score
     end
 
-    assert_equal <<~OUT, out
-      choice(1000): 0
-      choice(1000): 0
-      choice(#{big}): #{big}
-    OUT
-  end
+    def test_targeting_when_most_do_not_benefit
+      big = 10_000
 
-  def test_can_target_a_score_downwards
-    out, _ = capture_io do
-      assert_raises(Minitest::Assertion) do
-        run_test("can_target_a_score_downwards", database: {}, max_examples: 1000) do |test_case|
-          n = test_case.choice(1000)
-          m = test_case.choice(1000)
-          score = n + m
-          test_case.target(-score)
-          assert score.positive?
+      out, _ = capture_io do
+        assert_raises(Minitest::Assertion) do
+          run_test("targeting_when_most_do_not_benefit", database: {}, max_examples: 1000) do |test_case|
+            test_case.choice(1000)
+            test_case.choice(1000)
+            score = test_case.choice(big)
+            test_case.target(score)
+            assert score < big
+          end
         end
       end
+
+      assert_equal <<~OUT, out
+        choice(1000): 0
+        choice(1000): 0
+        choice(#{big}): #{big}
+      OUT
     end
 
-    assert_equal <<~OUT, out
-      choice(1000): 0
-      choice(1000): 0
-    OUT
-  end
+    def test_can_target_a_score_downwards
+      out, _ = capture_io do
+        assert_raises(Minitest::Assertion) do
+          run_test("can_target_a_score_downwards", database: {}, max_examples: 1000) do |test_case|
+            n = test_case.choice(1000)
+            m = test_case.choice(1000)
+            score = n + m
+            test_case.target(-score)
+            assert score.positive?
+          end
+        end
+      end
 
-  def test_prints_a_top_level_weighted
-    out, _ = capture_io do
-      assert_raises(Minitest::Assertion) do
-        run_test("prints_a_top_level_weighted", database: {}, max_examples: 1000) do |test_case|
-          assert test_case.weighted(0.5).nonzero?
+      assert_equal <<~OUT, out
+        choice(1000): 0
+        choice(1000): 0
+      OUT
+    end
+
+    def test_prints_a_top_level_weighted
+      out, _ = capture_io do
+        assert_raises(Minitest::Assertion) do
+          run_test("prints_a_top_level_weighted", database: {}, max_examples: 1000) do |test_case|
+            assert test_case.weighted(0.5).nonzero?
+          end
         end
       end
+
+      assert_equal <<~OUT, out
+        weighted(0.5): 0
+      OUT
     end
 
-    assert_equal <<~OUT, out
-      weighted(0.5): 0
-    OUT
-  end
+    def test_errors_when_using_frozen
+      tc = TestCase.for_choices([0])
+      tc.status = Status::VALID
 
-  def test_errors_when_using_frozen
-    tc = TestCase.for_choices([0])
-    tc.status = Status::VALID
+      assert_raises(Frozen) do
+        tc.mark_status(Status::INTERESTING)
+      end
 
-    assert_raises(Frozen) do
-      tc.mark_status(Status::INTERESTING)
-    end
+      assert_raises(Frozen) do
+        tc.choice(10)
+      end
 
-    assert_raises(Frozen) do
-      tc.choice(10)
+      assert_raises(Frozen) do
+        tc.forced_choice(10)
+      end
     end
 
-    assert_raises(Frozen) do
-      tc.forced_choice(10)
+    def test_errors_on_too_large_choice
+      tc = TestCase.for_choices([0])
+      assert_raises(RangeError) do
+        tc.choice(2 ** 64)
+      end
     end
-  end
 
-  def test_errors_on_too_large_choice
-    tc = TestCase.for_choices([0])
-    assert_raises(RangeError) do
-      tc.choice(2 ** 64)
+    def test_can_choose_full_64_bits
+      run_test("can_choose_full_64_bits", database: {}) do |tc|
+        tc.choice(2 ** 64 - 1)
+      end
     end
-  end
 
-  def test_can_choose_full_64_bits
-    run_test("can_choose_full_64_bits", database: {}) do |tc|
-      tc.choice(2 ** 64 - 1)
+    def test_mapped_possibility
+      run_test("mapped_possibility", database: {}) do |tc|
+        n = tc.any(integers(0, 5).map {|n| n * 2 })
+        assert n.even?
+      end
     end
-  end
 
-  def test_mapped_possibility
-    run_test("mapped_possibility", database: {}) do |tc|
-      n = tc.any(integers(0, 5).map {|n| n * 2 })
-      assert n.even?
+    def test_selected_possibility
+      run_test("selected_possibility", database: {}) do |tc|
+        n = tc.any(
+          integers(0, 5)
+            .satisfying(&:even?)
+        )
+        assert n.even?
+      end
     end
-  end
 
-  def test_selected_possibility
-    run_test("selected_possibility", database: {}) do |tc|
-      n = tc.any(
-        integers(0, 5)
-          .satisfying(&:even?)
-      )
-      assert n.even?
+    def test_bound_possibility
+      run_test("bound_possibility", database: {}) do |tc|
+        m, n = tc.any(
+          integers(0, 5).bind {|m| tuples(just(m), integers(m, m + 10)) }
+        )
+        assert (m..m+10).cover?(n)
+      end
     end
-  end
 
-  def test_bound_possibility
-    run_test("bound_possibility", database: {}) do |tc|
-      m, n = tc.any(
-        integers(0, 5).bind {|m| tuples(just(m), integers(m, m + 10)) }
-      )
-      assert (m..m+10).cover?(n)
+    def test_cannot_witness_nothing
+      assert_raises(Unsatisfiable) do
+        run_test("cannot_witness_nothing", database: {}) do |tc|
+          tc.any(nothing)
+        end
+      end
     end
-  end
 
-  def test_cannot_witness_nothing
-    assert_raises(Unsatisfiable) do
-      run_test("cannot_witness_nothing", database: {}) do |tc|
-        tc.any(nothing)
+    def test_cannot_witness_empty_mix_of
+      assert_raises(Unsatisfiable) do
+        run_test("cannot_witness_empty_mix_of", database: {}) do |tc|
+          tc.any(mix_of)
+        end
       end
     end
-  end
 
-  def test_cannot_witness_empty_mix_of
-    assert_raises(Unsatisfiable) do
-      run_test("cannot_witness_empty_mix_of", database: {}) do |tc|
-        tc.any(mix_of)
+    def test_can_draw_mixture
+      run_test("can_draw_mixture", database: {}) do |tc|
+        m = tc.any(mix_of(integers(-5, 0), integers(2, 5)))
+        assert (-5..5).cover?(m)
+        refute_equal 1, m
       end
     end
-  end
 
-  def test_can_draw_mixture
-    run_test("can_draw_mixture", database: {}) do |tc|
-      m = tc.any(mix_of(integers(-5, 0), integers(2, 5)))
-      assert (-5..5).cover?(m)
-      refute_equal 1, m
+    # This test is very hard to trigger without targeting, and targeting will
+    # tend to overshoot the score, so we will see multiple interesting test cases
+    # before shrinking.
+    def test_target_and_reduce
+      out, _ = capture_io do
+        assert_raises(Minitest::Assertion) do
+          run_test("target_and_reduce", database: {}) do |tc|
+            m = tc.choice(100_000)
+            tc.target(m)
+            assert m <= 99_900
+          end
+        end
+      end
+
+      assert_equal <<~OUT, out
+        choice(100000): 99901
+      OUT
     end
-  end
 
-  # This test is very hard to trigger without targeting, and targeting will
-  # tend to overshoot the score, so we will see multiple interesting test cases
-  # before shrinking.
-  def test_target_and_reduce
-    out, _ = capture_io do
-      assert_raises(Minitest::Assertion) do
-        run_test("target_and_reduce", database: {}) do |tc|
-          m = tc.choice(100_000)
-          tc.target(m)
-          assert m <= 99_900
+    def test_impossible_weighted
+      assert_raises(Failure) do
+        run_test("impossible_weighted", database: {}, quiet: true) do |tc|
+          tc.choice(1)
+          10.times do
+            assert false unless tc.weighted(0.0).zero?
+          end
+          raise Failure if tc.choice(1).zero?
         end
       end
     end
 
-    assert_equal <<~OUT, out
-      choice(100000): 99901
-    OUT
-  end
-
-  def test_impossible_weighted
-    assert_raises(Failure) do
-      run_test("impossible_weighted", database: {}, quiet: true) do |tc|
-        tc.choice(1)
-        10.times do
-          assert false unless tc.weighted(0.0).zero?
+    def test_guaranteed_weighted
+      assert_raises(Failure) do
+        run_test("guaranteed_weighted", database: {}, quiet: true) do |tc|
+          if tc.weighted(1.0).nonzero?
+            tc.choice(1)
+            raise Failure
+          else
+            assert false
+          end
         end
-        raise Failure if tc.choice(1).zero?
       end
     end
-  end
 
-  def test_guaranteed_weighted
-    assert_raises(Failure) do
-      run_test("guaranteed_weighted", database: {}, quiet: true) do |tc|
-        if tc.weighted(1.0).nonzero?
-          tc.choice(1)
-          raise Failure
-        else
-          assert false
-        end
+    def test_size_bounds_on_list
+      run_test("size_bounds_on_list", database: {}) do |tc|
+        ls = tc.any(lists(integers(0, 10), min_size: 1, max_size: 3))
+        assert (1..3).cover?(ls.length)
       end
     end
-  end
 
-  def test_size_bounds_on_list
-    run_test("size_bounds_on_list", database: {}) do |tc|
-      ls = tc.any(lists(integers(0, 10), min_size: 1, max_size: 3))
-      assert (1..3).cover?(ls.length)
+    def test_forced_choice_bounds
+      assert_raises(RangeError) do
+        run_test("forced_choice_bounds", database: {}) do |tc|
+          tc.forced_choice(2 ** 64)
+        end
+      end
     end
-  end
 
-  def test_forced_choice_bounds
-    assert_raises(RangeError) do
-      run_test("forced_choice_bounds", database: {}) do |tc|
-        tc.forced_choice(2 ** 64)
+    def test_failure_from_hypothesis_1
+      assert_raises(Failure) do
+        run_test("failure_from_hypothesis_1", database: {}, random: Random.new(100), max_examples: 1000, quiet: true) do |tc|
+          n1 = tc.weighted(0.0)
+          if n1.zero?
+            n2 = tc.choice(511)
+            if n2 == 112
+              n3 = tc.choice(511)
+              if n3 == 124
+                raise Failure
+              elsif n3 == 93
+                raise Failure
+              else
+                tc.mark_status(Status::INVALID)
+              end
+            elsif n2 == 93
+              raise Failure
+            else
+              tc.mark_status(Status::INVALID)
+            end
+          end
+        end
       end
     end
-  end
 
-  def test_failure_from_hypothesis_1
-    assert_raises(Failure) do
-      run_test("failure_from_hypothesis_1", database: {}, random: Random.new(100), max_examples: 1000, quiet: true) do |tc|
-        n1 = tc.weighted(0.0)
-        if n1.zero?
-          n2 = tc.choice(511)
-          if n2 == 112
-            n3 = tc.choice(511)
-            if n3 == 124
+    def test_failure_from_hypothesis_2
+      assert_raises(Failure) do
+        run_test("failure_from_hypothesis_2", database: {}, random: Random.new(0), max_examples: 1000, quiet: true) do |tc|
+          n1 = tc.choice(6)
+          if n1 == 6
+            n2 = tc.weighted(0.0)
+            if n2.zero?
               raise Failure
-            elsif n3 == 93
+            end
+          elsif n1 == 4
+            n3 = tc.choice(0)
+            if n3 == 0
               raise Failure
             else
               tc.mark_status(Status::INVALID)
             end
-          elsif n2 == 93
+          elsif n1 == 2
             raise Failure
           else
             tc.mark_status(Status::INVALID)
@@ -444,82 +471,57 @@ class Minitest::ThesisTest < Minitest::Thesis::Test
         end
       end
     end
-  end
 
-  def test_failure_from_hypothesis_2
-    assert_raises(Failure) do
-      run_test("failure_from_hypothesis_2", database: {}, random: Random.new(0), max_examples: 1000, quiet: true) do |tc|
-        n1 = tc.choice(6)
-        if n1 == 6
-          n2 = tc.weighted(0.0)
-          if n2.zero?
-            raise Failure
-          end
-        elsif n1 == 4
-          n3 = tc.choice(0)
-          if n3 == 0
-            raise Failure
+    def test_refactoring_cache
+      old = ->(node, choices, status) {
+        choices.each.with_index do |c, i|
+          if i + 1 < choices.length || status == Status::OVERRUN
+            node = if node.has_key?(c)
+                     node[c]
+                   else
+                     node[c] = {}
+                   end
           else
-            tc.mark_status(Status::INVALID)
+            node[c] = status
           end
-        elsif n1 == 2
-          raise Failure
-        else
-          tc.mark_status(Status::INVALID)
         end
-      end
-    end
-  end
+      }
 
-  def test_refactoring_cache
-    old = ->(node, choices, status) {
-      choices.each.with_index do |c, i|
-        if i + 1 < choices.length || status == Status::OVERRUN
+      new = ->(node, choices, status) {
+        *rest, last = choices
+        rest.each do |c|
           node = if node.has_key?(c)
                    node[c]
                  else
                    node[c] = {}
                  end
-        else
-          node[c] = status
         end
-      end
-    }
-
-    new = ->(node, choices, status) {
-      *rest, last = choices
-      rest.each do |c|
-        node = if node.has_key?(c)
-                 node[c]
-               else
-                 node[c] = {}
-               end
-      end
-      unless last.nil?
-        node[last] = status == Status::OVERRUN ? {} : status
-      end
-    }
+        unless last.nil?
+          node[last] = status == Status::OVERRUN ? {} : status
+        end
+      }
 
-    run_test("refactoring_cache", database: {}) do |tc|
-      old_tree = {}
-      new_tree = {}
+      run_test("refactoring_cache", database: {}) do |tc|
+        old_tree = {}
+        new_tree = {}
 
-      choices = tc.any(lists(integers(0, 10)))
-      status = Status.new(tc.choice(4))
+        choices = tc.any(lists(integers(0, 10)))
+        status = Status.new(tc.choice(4))
 
-      old.(old_tree, choices, status)
-      new.(new_tree, choices, status)
+        old.(old_tree, choices, status)
+        new.(new_tree, choices, status)
 
-      assert_equal old_tree, new_tree
+        assert_equal old_tree, new_tree
+      end
     end
-  end
 
-  private
+    private
 
-  def suppress_warnings
-    original_verbosity = $VERBOSE
-    $VERBOSE = nil
-    yield
-    $VERBOSE = original_verbosity
+    def suppress_warnings
+      original_verbosity = $VERBOSE
+      $VERBOSE = nil
+      yield
+      $VERBOSE = original_verbosity
+    end
   end
 end