Generative Laws with tinytest

library(S7)
library(s7contract)
tinytest::using(s7contract)

Generative laws separate three concerns: generators construct examples, laws state behavior over those examples, and a runner searches for a smaller counterexample. One call to expect_law() becomes one tinytest result even though the law is evaluated many times.

In s7contract, these laws provide behavioral evidence for protocols whose operations are described by interfaces or traits. The vector protocol vignette defines one VectorLike law suite, runs it against two implementations, and finds a faulty slice method that satisfies the interface.

A tinytest property

The generator below produces integer vectors and carries an integrated shrink tree. The law checks that reversing a vector twice returns the original value.

reverse_law <- new_law(
  "reverse is involutive",
  generators = list(
    x = gen_vector(gen_integer(-100L, 100L), max = 20L)
  ),
  holds = function(x) identical(rev(rev(x)), x)
)

expect_law(reverse_law, tests = 100L, seed = 20260902L)
#> ----- PASSED      : <-->
#>  call| expect_law(reverse_law, tests = 100, seed = 20260902)
#>  info| Law 'reverse is involutive' passed 100 tests (seed 20260902).

Laws over an S7 interface

Generate non-negative radii to construct valid Circle objects. The law checks their areas through an interface that requires a double return value.

Circle <- new_class(
  "CirclePropertyVignette",
  properties = list(radius = class_double),
  validator = function(self) {
    if (self@radius < 0) "`radius` must be non-negative."
  }
)
area <- new_generic(
  "area_property_vignette",
  "x",
  function(x) S7_dispatch()
)
method(area, Circle) <- function(x) pi * x@radius^2

HasArea <- new_interface(
  "HasAreaPropertyVignette",
  generics = list(
    area = interface_requirement(area, returns = class_double)
  )
)
circles <- gen_map(
  gen_double(0, 1000),
  function(radius) Circle(radius = radius)
)
area_law <- new_law(
  "non-negative radii have non-negative area",
  generators = list(x = circles),
  holds = function(x) with(HasArea, area(x)) >= 0
)

expect_law(area_law, tests = 100L, seed = 20260902L)
#> ----- PASSED      : <-->
#>  call| expect_law(area_law, tests = 100, seed = 20260902)
#>  info| Law 'non-negative radii have non-negative area' passed 100 tests (seed 20260902).

Inspecting and replaying a failure

Use check_law() when the structured result is needed independently of a test framework. This deliberately false law starts at ten and shrinks to zero.

ten <- new_generator(
  draw = function(size) 10L,
  shrink = function(value) {
    if (value == 0L) list() else list(0L, value %/% 2L)
  },
  label = "ten",
  prototype = integer()
)
negative_law <- new_law(
  "generated values are negative",
  generators = list(x = ten),
  holds = function(x) x < 0L
)

failure <- check_law(negative_law, tests = 10L, seed = 20260902L)
failure
#> Law 'generated values are negative' was falsified after 1 attempts and 1 shrinks (seed 20260902).
#> The law returned FALSE.
#> Shrinking stopped: no child of this counterexample preserves the failure.
#> Smallest counterexample found:
#> List of 1
#>  $ x: int 0

The result records the seed, RNG kind, run parameters, original input, final counterexample, and shrink counts. The minimal field holds the last accepted failing candidate along the ordered shrink path, not a guaranteed smallest failure. shrink_status distinguishes exhaustion of the current candidate’s children, an evaluation budget, a shrinking error, and a run that needed no shrinking. If shrinking errors or warns, shrink_condition records that problem while the original and last failing examples remain available.

Replay the same law and parameters with ordinary R function application:

replayed <- do.call(check_law, c(list(law = failure@law), failure@parameters))
identical(replayed@counterexample@minimal, failure@counterexample@minimal)
#> [1] TRUE

Runs use Mersenne-Twister, Inversion normals, and Rejection sampling, so changing the caller’s RNG kind does not change the generated sequence. Replay requires unchanged generator and law code, run parameters, and compatible R/package versions. Generators and laws must not depend on external mutable state or change the RNG configuration. Stored examples can still be tested directly when the generator changes.

The caller’s RNG kind and state are restored on exit. Box-Muller normals are unsupported because R does not expose their cached draw for restoration; select another normal RNG kind before running laws.

Composition and shrinking

Mapping transforms both the generated value and visited shrinks. Products combine independent generators and shrink one component at a time. Vectors remove contiguous chunks and then shrink elements, retaining their minimum length. Nesting vector generators produces lists of vectors, including empty inner vectors:

nested <- new_law(
  "nested vectors retain their element type",
  generators = list(x = gen_vector(gen_vector(gen_integer(), max = 4L), max = 3L)),
  holds = function(x) is.list(x) && all(vapply(x, is.integer, logical(1)))
)
expect_law(nested, tests = 20L, seed = 1L)
#> ----- PASSED      : <-->
#>  call| expect_law(nested, tests = 20, seed = 1)
#>  info| Law 'nested vectors retain their element type' passed 20 tests (seed 1).

The runner constructs and transforms each shrink candidate only when visited. shrinks = 0L performs no shrink expansion. A custom shrink function still constructs its own list of candidates; the evaluation budget cannot bound the work performed inside user functions. Shrinkers and mapping functions must be deterministic, and mapped constructors must accept every visited shrink.

Dependent inputs

gen_bind() uses one generated value to construct the next generator. Here the sequence length and its bases belong to one dependent input. Every shrink still has exactly the declared length, so the law needs no discarded preconditions.

sequences <- gen_bind(gen_integer(0L, 20L), function(n) {
  gen_product(
    length = gen_constant(n),
    bases = gen_vector(gen_element(c("A", "C", "G", "T")), min = n, max = n)
  )
})
sequence_law <- new_law(
  "sequence length matches its declaration",
  generators = list(x = sequences),
  holds = function(x) length(x$bases) == x$length
)
expect_law(sequence_law, tests = 40L, seed = 1L)
#> ----- PASSED      : <-->
#>  call| expect_law(sequence_law, tests = 40, seed = 1)
#>  info| Law 'sequence length matches its declaration' passed 40 tests (seed 1).
gen_example(sequences, size = 10L, seed = 42L)
#> $length
#> [1] 0
#> 
#> $bases
#> character(0)

Shrinking first tries smaller source values and rebuilds the dependent generator, then shrinks its result. Each rebuild uses the same captured local seed and size. Random draws inside the law therefore do not change the regenerated candidates. The factory must depend only on its input and the scoped RNG, and its constructors must accept every visited source shrink.

Choices and recursive values

gen_element() chooses a value; gen_choice() chooses a generator. Entries are ordered from simpler to more complex for shrinking. Optional prob weights control sampling; zero-weight entries are excluded from generation and shrinking. All entries with positive weight are available even at size zero.

nullable <- gen_choice(gen_constant(NA_integer_), gen_integer(), prob = c(1, 9))
gen_example(gen_vector(nullable, min = 6L, max = 6L), size = 10L, seed = 42L)
#> [1] NA  4  1 -4 -7 NA

gen_sized() builds a generator from the runner’s current size. gen_resize() overrides the size for one generator while leaving sibling generators alone. gen_recursive() supplies its expansion function with a child generator that uses half the current size, rounded down. At size zero, only the base generator runs. This supports nested lists, expression trees, or recursive S7 objects.

trees <- gen_recursive(
  gen_element(c("A", "C", "G", "T")),
  function(child) gen_product(left = child, right = child)
)
gen_example(trees, size = 7L, seed = 42L)
#> [1] "G"

leaf_count <- function(tree) {
  if (is.list(tree)) leaf_count(tree$left) + leaf_count(tree$right) else 1L
}
tree_law <- new_law(
  "binary trees have at least one leaf",
  generators = list(tree = trees),
  holds = function(tree) leaf_count(tree) >= 1L
)
expect_law(tree_law, tests = 30L, seed = 1L, max_size = 7L)
#> ----- PASSED      : <-->
#>  call| expect_law(tree_law, tests = 30, seed = 1, max_size = 7)
#>  info| Law 'binary trees have at least one leaf' passed 30 tests (seed 1).

Recursive size bounds depth, not total node count; the expansion function still controls branching. Shrinking can replace a recursive value with a base value before shrinking within a branch. gen_no_shrink() removes a generator’s shrinking when a value must remain fixed during the search. gen_example() draws one value without expanding shrinks and restores the caller’s RNG state.

Relationship to Hedgehog

Generation and shrinking follow R Hedgehog and Haskell Hedgehog: generators carry lazy rose trees, preserving shrinking through composition. The corresponding operations in s7contract are:

Concept s7contract
Mapping / functor composition gen_map() transforms values and their shrink trees.
Independent / applicative composition gen_product() and named law arguments combine independent generators.
Dependent / monadic composition gen_bind() rebuilds downstream generators when upstream inputs shrink.
Size-aware generation Integer ranges and vector lengths grow with size; gen_sized() and gen_resize() expose size control.
Choice and recursive generation gen_element(), gen_choice(), and gen_recursive() retain integrated shrinking.
Inspection and shrink control gen_example() draws a reproducible value; gen_no_shrink() removes shrinking.
State-machine testing new_command(), gen_commands(), and new_state_law() test sequential protocols against a model.
Case coverage classify labels generated inputs; min_coverage requires observed proportions within the test budget. See the vector example.
Numeric domains gen_double() generates finite fractional values with a shrink origin; gen_choice() adds exceptional values with explicit weights.
Selection domains gen_sample() preserves sample cardinality and distinct source positions; gen_subsequence() preserves source order. See the vector laws.
Strings and calendar dates Composed recipes exercise UTF-8 store keys and whole-day intervals.
Behavioral contracts Laws can exercise S7 interfaces and traits through ordinary calls.
Test-framework integration check_law() returns structured results; expect_law() records one tinytest result.