knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
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.
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)
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)
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
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)
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.
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)
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.
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) gen_example(sequences, size = 10L, seed = 42L)
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.
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)
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) 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)
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.
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. |
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.