Skip to content

Feat: Automatically Derive Generators for Algebraic Data Types#35

Closed
ngernest wants to merge 60 commits intoleanprover-community:mainfrom
ngernest:main
Closed

Feat: Automatically Derive Generators for Algebraic Data Types#35
ngernest wants to merge 60 commits intoleanprover-community:mainfrom
ngernest:main

Conversation

@ngernest
Copy link
Copy Markdown

@ngernest ngernest commented Jul 28, 2025

Overview

This PR introduces a Deriving Handler for deriving type-based generators for algebraic data types, similar to generic-random in Haskell and ppx_quickcheck in OCaml.

To facilitate this, this PR also introduces an Arbitrary typeclass, akin to Haskell QuickCheck and Rocq QuickChick:

class Arbitrary (α : Type) where
  /-- A random generator for values of the given type. -/
  arbitrary : Gen α

Note that Plausible's current Gen type constructor is not a typeclass, which is why we need to introduce a separate Arbitrary class. We have typeclass infrastructure set up so that every type which has an instance of Plausible's existing SampleableExt class automatically gets an instance of Arbitrary.
(Note: SampleableExt describes types which have both a generator & a shrinker, whereas Arbitrary describes types which have a generator only.)

Users can write deriving Arbitrary after an inductive type definition, e.g.

-- Datatype for binary trees
inductive Tree
  | Leaf : Tree
  | Node : Nat → Tree → Tree → Tree
  deriving Arbitrary

This derives a generator for random inhabitants of Tree.

We also support deriving Arbitrary instances for structures with named fields:

structure Foo where
  boolField : Bool
  natField : Nat
  deriving Arbitrary

Instead of writing deriving Arbitrary, users can also write deriving instance Arbitrary for T1, ..., Tn as a top-level command to derive Arbitrary instances for types T1, ..., Tn simultaneously.

For debugging purposes, we also provide a command elaborator for the #derive_arbitrary command:

-- `#derive_arbitrary` derives an instance of `Arbitrary` for `Tree`
#derive_arbitrary Tree  

Running this command prints the code for the derived generator to stdout. (Note that the definition of the derived generator is already registered at this point.)

To sample from a derived generator, users can simply call Arbitrary.runArbitrary, specify the type
for the desired generated values and provide some Nat to act as the generator's size parameter (10 in the example below):

#eval Arbitrary.runArbitrary (α := Tree) 10

Tests

  • See the files in the Test/DeriveArbitrary subdirectory for snapshot tests, in which we use the #guard_msgs command to check that the code for the derived generator coheres with the docstrings in the test file
  • Run lake test to run all snapshot tests (this also ensures that the code for the example derived generators type check)

Linter

This PR also adds a linter to the repo -- we use the default linter from Batteries , as described in the Lean wiki.

Overview of files (listed in suggested review order)

  • Arbitrary.lean: The Arbitrary & ArbitraryFueled typeclasses for unconstrained generators, adapted from QuickChick
  • DeriveArbitrary.lean: Deriver for unconstrained generators (instances of the Arbitrary / ArbitrarySized typeclasses)
  • Test/DeriveArbitrary: snapshot tests containing derived generators for example inductive types

@ngernest ngernest marked this pull request as ready for review July 28, 2025 20:11
Copy link
Copy Markdown
Collaborator

@hargoniX hargoniX left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, first of all thanks so much for doing this! I had contemplated to implement this on multiple occasions in the past but unfortunately never found the time for it. I think this can really do a lot to push plausible adoption forward!

I left a few conceptual comments along the PR and didn't take a look at the implementations in detail yet.

- In QuickChick, this typeclass is called `Gen`, but `Gen` is already
a reserved keyword in Plausible, so we call this typeclass `Arbitrary`
following the Haskell QuickCheck convention). -/
class Arbitrary (α : Type) where
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is a good long term design decision to partially replicate Sampleable functionality in a second type class that lives next to it. To me the two sensible choices seem to be:

  • Either we just drop Arbitrary entirely and build a deriving handler for Sampleable that, at least for now, uses the id shrinker function.
  • SampleableExt is refactored to be based on both Arbitrary and Shrinkable

can you explain why you went for neither of those?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks -- both are good suggestions!

For context, this PR is part of a broader project to replicate Rocq QuickChick's functionality for deriving generators that produce data satisfying user-defined inductive predicates (this PR is spun off this repo).

Part of this broader project involves defining a ArbitrarySuchThat typeclass:

/-- Generators of type `α` such that `P : α -> Prop` holds for all generated values.
    Note that these generators may fail, which is why they have type `OptionT Gen α`. -/
class ArbitrarySuchThat (α : Type) (P : α → Prop) where
  arbitraryST : OptionT Gen α

For simplicity / ease of comparison with QuickChick, I tried to make the derived generators in ArbitrarySuchThat be as close as possible to the generators produced by QuickChick, which resulted in me adopting the same typeclass definitions as QuickChick. (I also wanted to avoid modifying Plausible internals, which is why I adopted to make Arbitrary its own typeclass instead of re-factoring SampleableExt directly.)

This was useful for rapid prototyping, but I agree with you that it's a bad idea to have Arbitrary replicate some of SampleableExt's functionality.

I think the best solution moving forward might be to refactor SampleableExt to be based on both Arbitrary and Shrinkable -- this makes the design more modular and separates the generation/shrinking logic. What do you think?

@codyroux
Copy link
Copy Markdown
Contributor

Thanks a bunch for your feedback @hargoniX!

- In QuickChick, this typeclass is called `Gen`, but `Gen` is already
a reserved keyword in Plausible, so we call this typeclass `Arbitrary`
following the Haskell QuickCheck convention). -/
class Arbitrary (α : Type) where
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What makes this typeclass different from Plausible.Random Id?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah sorry, this is an easy question; Arbitrary is a version of Random with a "size" parameter.

Copy link
Copy Markdown
Author

@ngernest ngernest Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, you're correct! Having a dedicated typeclass for generation (Arbitrary) allows us to maintain a similar design to Haskell QuickCheck & Rocq QuickChick and makes it easier to refer to the generators for a particular type in the body of derived generators.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes it easier to refer to the generators for a particular type in the body of derived generators.

Is Plausible.Random.random really any harder to refer to than Arbitrary.arbitrary? Maybe let's resolve the size argument issue below before continuing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, you're right -- thanks! Random Id is basically the same as Arbitrary.

(The following explanation is purely for my own understanding / for documentation purposes)

Plausible.Random.random has type RandGT g m α. If we set g = StdGen and m = Id, then unfolding definitions gives us:

RandGT StdGen Id α
≡ RandG StdGen α            -- since for any `g`, we have `RandG g := RandGT g Id`
≡ Rand α                             -- by definition of `Rand`

Gen α is defined as ReaderT (ULift Nat) Rand α, so under the hood, Arbitrary.arbitrary, which has type Gen α is using the same Rand α random generator as Plausible.Random.random.

I guess in this case, the main benefit of having an Arbitrary typeclass is that it maintains the same naming convention as Haskell QuickCheck (which one could argue is somewhat tenuous), and that Arbitrary serves as an abbreviation for Random Id, which may make the deriving infrastructure easier to implement (Random Id is a partially-applied typeclass whereas Arbitrary isn't).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to go that route, I'd suggest abbrev Arbitrary α := Random Id α.

Note that the idea in Random m α is that some types such as Expr are much easier to sample in interesting ways in MetaM. It's totally fair to declare the m a distraction and specialize to Id in this Pr, but I think creating a brand new typeclass is a bad idea.

Copy link
Copy Markdown
Author

@ngernest ngernest Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah that's fair, thanks for the suggestion! I'll defer to @hargoniX on whether to keep Arbitrary since we were discussing in another thread above whether to keep Arbitrary as a new typeclass or to refactor the existing SampleableExt typeclass.

Comment on lines +55 to +57
/-- Takes a `Nat` and produces a random generator dependent on the `Nat` parameter
(which indicates the size of the output) -/
arbitrarySized : Nat → Gen α
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't Gen already have this size parameter built in?

Copy link
Copy Markdown
Author

@ngernest ngernest Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, you're correct that Gen already has a size parameter built-in! However, when deriving proofs of soundness + completeness for the derived generators (which we're currently working on in a separate PR), as observed in Generating Good Generators for Inductive Relations (POPL 2018), it is convenient to have a separate typeclass which makes the generator's size parameter explicit (i.e. ArbitrarySized in this PR). Specifically, section 5.1 of the paper discusses how the semantics of Gen (in Rocq QuickChick, but a similar argument can be made for Plausible generators) are defined by the set of values that are generated for a particular size parameter, and how reasoning about monotonicity with respect to generators' size parameter leads to natural specifications for generator combinators.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But to be clear, this is currently defined as arbitrarySized : Nat -> ULift Nat -> Plausible.Rand α (after unfolding), where both Nats are the size. If you genuinely have two different sizes at play, then the docstring should distinguish them.

Are you sure that our Gen corresponds to the one QuickCh(e|i)ck and not to something else? Right now your Arbitrary is a generator with one size argument, and ArbitrarySized is a generator with two size arguments, which seems like an off-by-one-error.

Copy link
Copy Markdown
Author

@ngernest ngernest Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah you're right, sorry, I missed that Gen contains an ULift Nat argument after unfolding -- thanks for the heads-up! In this case, the Nat parameter in (what is currently called) ArbitrarySized acts more like fuel -- the derived ArbitrarySized generator recurses on this Nat parameter and terminates when the Nat is 0.

I've renamed ArbitrarySized to ArbitraryFueled and updated the docstrings -- hopefully that's ok!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "terminates" mean? Does this mean that if sampling random elements of List Nat, then this will sample both smaller Nats and smaller Lists? I think this might already be the intended semantics of the ULIft Nat argument, and so you might want just arbitrarySized : Gen α

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, by "terminates", I mean that the generator picks one of the values in its base case to return. As an example, here is the generator that is derived for a binary tree datatype:

inductive Tree
  | Leaf : Tree
  | Node : Nat → Tree → Tree → Tree

-- Derived code below
-- (The code for the derived generator closely follows the generator that Rocq QuickChick derives)
instance : Plausible.ArbitraryFueled Tree where
      arbitraryFueled :=
        let rec aux_arb (fuel : Nat) : Plausible.Gen Tree :=
          match size with
          | Nat.zero => Plausible.Gen.oneOfWithDefault (pure Tree.Leaf) [(pure Tree.Leaf)]
          | size' + 1 =>
            Plausible.Gen.frequency (pure Tree.Leaf)
              [(1, (pure Tree.Leaf)),
                (fuel' + 1,
                  (do
                    let a_0 ← Plausible.Arbitrary.arbitrary
                    let a_1 ← aux_arb fuel'
                    let a_2 ← aux_arb fuel'
                    return Tree.Node a_0 a_1 a_2))]
        fun fuel => aux_arb fuel

When fuel = 0, we just return a Leaf.

For the List Nat example, the generator would just return the empty list when fuel = 0.

Copy link
Copy Markdown

@kmill kmill left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your contribution.

I'm not a maintainer for this repository per se, but I have some comments.

Is there any particular reason you're not using some of the logic that you can find in other deriving handlers, e.g. in Deriving.Repr, which supports nested and mutually recursive inductive types, with parameters? The techniques there would make it possible to derive Arbitrary instances for types like List.

- Note: Plausible's `SampleableExt` is analogous to QuickChick's `Arbitrary` typeclass
(which combines QuickChick's `Gen` and `Shrink` typeclass)-/
instance [Repr α] [Shrinkable α] [ArbitrarySized α] : SampleableExt α :=
SampleableExt.mkSelfContained (Gen.sized ArbitrarySized.arbitrarySized)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you start with a SampleableExt, does this instance with the next one produce an equivalent SampleableExt? If not, then there should not be such an instance loop.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks -- good catch! I removed this instance since it's not strictly necessary (all tests pass without this instance).

| [] => `(True)
| [p] => pure p
| p :: ps =>
List.foldlM (fun acc pred => `($acc && $pred)) (init := p) ps
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A function that creates an n-ary and from an array of terms seems potentially useful, but a combinator to create an if doElem doesn't.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah that's fair, thanks -- I've removed the TSyntaxCombinators.lean file altogether since the combinators there are of dubious utility for this PR.

let mut doElems := #[]

-- Determine whether the constructor has any recursive arguments
let ctorIsRecursive ← isConstructorRecursive targetTypeName ctorName
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let ctorIsRecursive ← isConstructorRecursive targetTypeName ctorName
let mut ctorIsRecursive := false

then set it when you detect it is recursive.

It's better design not to duplicate logic, where it's implemented in slightly different ways each time. The source of truth is this loop.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a very good point! Adopted this change.

- Note: this function only considers constructors with arrow types -/
def isConstructorRecursive (inductiveName : Name) (ctorName : Name) : MetaM Bool := do
let ctorInfo ← getConstInfo ctorName
let ctorType := ctorInfo.type
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't recursive mean that ctorType contains ctorName anywhere?

Option.isSome <| ctorType.find? (·.isConstOf inductiveName)

This would admit some more possibilities than what the deriving handler actually allows however.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks -- I removed the Utils.lean file altogether since this function is no longer strictly necessary. (This function was used in the parent project to analyze constructors of inductive Props when deriving generators of values satisfying the Prop, which is outside the scope of this PR.)

@ngernest
Copy link
Copy Markdown
Author

@kmill Thanks for your feedback! Re: why this PR doesn't use some of the existing logic in deriving handlers, this PR is part of a broader project (repo) to derive generators of random values that satisfy some user-specified inductive proposition (i.e. some inductive that is a Prop). The logic for deriving these constrained generators requires introspecting the metadata associated with the user-provided inductive proposition (and closely follows existing algorithms described in the literature), and thus deviates a bit from the existing logic for deriving handlers. To maintain consistency with the rest of the aforementioned repo, this PR doesn't re-use existing deriving handler logic, although I'm happy to make that change if needed. Hopefully this makes sense!

@ngernest
Copy link
Copy Markdown
Author

ngernest commented Jul 31, 2025

I'm happy to say that the deriving Arbitrary handler now supports parameterized types / mutually recursive types!
Examples can be found in ParameterizedTypeTest.lean and MutuallyRecursiveTypeTest.lean.

To support this, I re-implemented the handler following the idioms for the deriving Repr / BEq handlers. One benefit of this is that all the logic now fits in one single file (DeriveArbitrary.lean), and I was able to prune a few files from the PR. Thank you @kmill for your suggestions! (I think I've now addressed all the code change suggestions.)

Other high-level changes:

  • We now use Core.mkFreshUserName (instead of LocalContext.getUsedName) to produce fresh names. As a result, the derived Arbitrary instance is meant to be opaque.
  • Every test now comes with an example of how the derived generator produces a counter-example that refutes some (erroneous) property, as suggested by @hargoniX .
  • Instead of a command elaborator, we have a trace class trace.plausible.deriving.arbitrary that prints the code for the derived generator if the user enables the option.

I think the main outstanding question, as pointed out by @codyroux, is whether Arbitrary should be its own typeclass or not.

Copy link
Copy Markdown
Collaborator

@hargoniX hargoniX left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding Arbitrary vs SampleableExt, I do not really have stakes in how this is resolved, the two important points to me would be:

First I don't think it is reasonable UX to have the user write:

instance [Repr α] [Shrinkable α] [Arbitrary α] : SampleableExt (MyList α) :=
  SampleableExt.mkSelfContained Arbitrary.arbitrary

after every deriving Arbitrary. Whatever we derive should allow them to run plausible with the type right away without further complications.

As for how the Arbitrary vs other typeclasses is resolved, as we have established in one of the threads Arbitrary is really Random Id, this, to me, indicates very clearly that having Arbitrary is a bit of a weird design choice. I understand, not wanting to write a full SampleableExt deriving handler though.

What about the following approach: We split up SampleableExt into its sampling and its shrinking component as two type classes (the shrinking already being split out as Shrinkable). We then define a default_instance for Shrinkable that is just fun _ => [], this allows users to still handle Shrinkable on their own if they so desire. Then we finally define an instance that combines a sampling and a Shrinkable instance to a SampleableExt instance which then gets fed into the rest of the framework.

As for the design of the sampling component a few points that seem sensible to me though I haven't read all of the QuickChick literature:

  • I think it should maintain the ability to sample through a proxy type (for things such as functions), the deriving handling for the sampling component can make that part just be id. If there are other ways to sample functions I'd also be open to that.
  • Please do try to keep it simple, I think the double parameter with Gen and the fuel that we have in ArbitraryFueled would be very confusing for users getting into this framework, ideally there should only be one parameter (as you mention in one of the doc-strings QuickChick already does this so this seems doable?)

Note that plausible currently has a basically non existing userbase for its internal implementation details so you can basically do whatever refactoring you want at this point in time. Given that breaking SampleableExt up can be done entirely separately from building a deriving handler for its sampling component this can probably be done in a separate PR to ease the review as well.

@@ -0,0 +1,81 @@
/-
Copyright (c) 2025 Ernest Ng. All rights reserved.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a more general note, are you sure it is you that has copyright here and not AWS, given that you are being paid?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I previously asked @codyroux and he said to put my name here (we've received permission to open-source this code). I'll double check with him!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll check.

@codyroux
Copy link
Copy Markdown
Contributor

codyroux commented Aug 1, 2025

Regarding Arbitrary vs SampleableExt, I do not really have stakes in how this is resolved, the two important points to me would be:

First I don't think it is reasonable UX to have the user write:

instance [Repr α] [Shrinkable α] [Arbitrary α] : SampleableExt (MyList α) :=
  SampleableExt.mkSelfContained Arbitrary.arbitrary

after every deriving Arbitrary. Whatever we derive should allow them to run plausible with the type right away without further complications.

As for how the Arbitrary vs other typeclasses is resolved, as we have established in one of the threads Arbitrary is really Random Id, this, to me, indicates very clearly that having Arbitrary is a bit of a weird design choice. I understand, not wanting to write a full SampleableExt deriving handler though.

What about the following approach: We split up SampleableExt into its sampling and its shrinking component as two type classes (the shrinking already being split out as Shrinkable). We then define a default_instance for Shrinkable that is just fun _ => [], this allows users to still handle Shrinkable on their own if they so desire. Then we finally define an instance that combines a sampling and a Shrinkable instance to a SampleableExt instance which then gets fed into the rest of the framework.

As for the design of the sampling component a few points that seem sensible to me though I haven't read all of the QuickChick literature:

* I think it should maintain the ability to sample through a proxy type (for things such as functions), the deriving handling for the sampling component can make that part just be `id`. If there are other ways to sample functions I'd also be open to that.

* Please do try to keep it simple, I think the double parameter with `Gen` and the fuel that we have in `ArbitraryFueled` would be very confusing for users getting into this framework, ideally there should only be one parameter (as you mention in one of the doc-strings QuickChick already does this so this seems doable?)

Note that plausible currently has a basically non existing userbase for its internal implementation details so you can basically do whatever refactoring you want at this point in time. Given that breaking SampleableExt up can be done entirely separately from building a deriving handler for its sampling component this can probably be done in a separate PR to ease the review as well.

Ok. After doing some soul searching and repo diving, here's my proposal for the medium term.

  1. Table this PR for now, given @ngernest 's time constraints and what I'm about to say next. I'll then try to do the following:
  2. Refactor Gen to actually just be a Plausible.Random (Reader (ULift Nat)) instead of the other way around, which allows us to now derive instances of Gen. (In fact I wonder why BoundedRandom isn't defined that way). Or maybe even Plausible.Random (ReaderT (ULift Nat) m) so we keep the base monad.
  3. Now SampleableExt has 2 classes as fields. It's a little sad to me that we can't have SampleableExt extends Random, Shrinkable but I take your point with the Repr stuff.
  4. Submit that as a standalone PR.
  5. Get rid of all the fuel stuff, though I still feel that it is pretty useful as a separate concept, but I think I can make everything total.
  6. Submit the updated deriving stuff.

As a side note, the default Shrinkable instance is fun a => [a] aka List.return, no?

I'd like to be able to fit this all into a monad, to be able to e.g. lift failure but I think that'd be more work.

Any thoughts about this?

@eric-wieser
Copy link
Copy Markdown
Member

2. Refactor Gen to actually just be a Plausible.Random (Reader (ULift Nat)) instead of the other way around, which allows us to now derive instances of Gen. (In fact I wonder why BoundedRandom isn't defined that way).

BoundedRandom is about sampling elements that fit within a range, not about sample elements for a given fuel like Gen is.

I guess this probably raises that we are missing MonadReader, MonadState etc instances for Plausible.Random, which sound like a good PR in their own right.

@ngernest
Copy link
Copy Markdown
Author

Closing this PR since #41 (a clone of this PR) has been merged. (Thanks @codyroux!)

@ngernest ngernest closed this Oct 15, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants