Skip to content

stdlib: add property based testing#1559

Draft
saem wants to merge 87 commits intonim-works:develfrom
saem:saem-property-based-testing
Draft

stdlib: add property based testing#1559
saem wants to merge 87 commits intonim-works:develfrom
saem:saem-property-based-testing

Conversation

@saem
Copy link
Copy Markdown
Collaborator

@saem saem commented Jul 5, 2025

Summary

Introduces experimental property based testing support via the property_testing module in the stdlib, available under C and JS backends.

Details

Create an integrated shrinking based approach to property based testing. This is based off the work in Python Hypothesis test library by Dr David R. Maciver.

The key benefits of this approach are:

  • generators/types do not have to specify shrinking strategies, instead a unified set of strategies are applied to the backing random byte stream
  • generator authors automatically get a very good shrinker with no effort on their part
  • arbitrarily complex generators, those composed of many generators, will automatically have shrinking support as well
  • it's very fast

The library ships with a number of basic generators, for scalar primitives such as numerics, booleans, characters, and the like. As well as vector like types, such as arrays, sequences, sets, and so on. Also generators for Tuples and procedural values (must be closures). In addition to these types a variety of combinators, map, filter, etc have been provided to allow composition of generators to create These provide the foundational primitives to compose arbitrarily complex generation of data types that a user might want.


Notes for Reviewers

  • write a proper commit message
  • Disclosure: some of this code was written with the assistance of the an LLM (i.e.: Gemini)

@saem saem added the stdlib Standard library label Jul 5, 2025
@saem saem marked this pull request as draft July 5, 2025 22:11
@saem
Copy link
Copy Markdown
Collaborator Author

saem commented Aug 30, 2025

The output from the current tests looks as follows, each property has an id that should be useable to run it specifically in the future:

nim

1 uint32
- are >= 0 (id: 1) - status: success, totalRuns: 1000
- within the range[100000000, 4294967295] (id: 2) - status: success, totalRuns: 1000

1.1 enums
- are typically ordinals (id: 3) - status: success, totalRuns: 1000

1.2 characters
        1.2.1 are ordinals
        - forming a bijection with int values between 0..255 (inclusive) (id: 4) - status: success, totalRuns: 1000
        - have successors and predecessors or are at the end range (id: 5) - status: success, totalRuns: 1000

- ascii - are from 0 to 127 (id: 6) - status: success, totalRuns: 1000

1.3 strings
- concatenation - len is == the sum of the len of the parts (id: 7) - status: success, totalRuns: 1000

1.4 sets
- cannot contain more items than the enum itself (id: 8) - status: success, totalRuns: 1000
        1.4.1 union
        - a union of sets contain all elements of each (id: 9) - status: success, totalRuns: 1000
        - union is commutative (id: 10) - status: success, totalRuns: 1000

        1.4.2 intersection
        - an intersection is a subset of both operands (id: 11) - status: success, totalRuns: 1000
        - intersection is commutative (id: 12) - status: success, totalRuns: 1000

        1.4.3 difference (or relative complement)
        - a difference has no overlap with the second operand (id: 13) - status: success, totalRuns: 1000

saem added 4 commits March 24, 2026 10:03
some test checks were weaker than they could be, only checking
if failure was detected even if failure was guaranteed.

clean-up:
- trailing white spaces
- superfluous comments
@saem
Copy link
Copy Markdown
Collaborator Author

saem commented Mar 25, 2026

There are places for improvement but I'm not sure exactly where to take it without more experience with the library.

So with that said I think this is ready to be reviewed and merged.

@zerbina zerbina self-requested a review March 25, 2026 16:20
@saem
Copy link
Copy Markdown
Collaborator Author

saem commented Mar 26, 2026

I'm heavily reworking the tests:

  • splitting them up into more tests suites:
    • public api/how to use them
    • core primitives, both for generator authors, but also further library/application builders
    • documentation of various use cases, such as generator composition, and also pushing potential exhaustion
    • multi-overlapping testing of shrinking, candidate selection, and ast parsing (structured byte generation)
  • expanding their coverage
  • removing redundant code, especially around exhaustive generator tests

The above found some bugs, so I'll flush those out as well.

Comment thread lib/experimental/property_testing.nim Outdated
Comment on lines +881 to +888
for (pos, kind) in nodes:
if kind in {skArray8, skArray16, skArray32}:
let lenBytes =
case kind
of skArray8: 1
of skArray16: 2
of skArray32: 4
else: unreachable("Invalid kind: " & $kind)
Copy link
Copy Markdown
Collaborator Author

@saem saem Mar 28, 2026

Choose a reason for hiding this comment

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

Suggested change
for (pos, kind) in nodes:
if kind in {skArray8, skArray16, skArray32}:
let lenBytes =
case kind
of skArray8: 1
of skArray16: 2
of skArray32: 4
else: unreachable("Invalid kind: " & $kind)
for (pos, kind) in nodes:
if kind == skArray8 or kind == skArray16 or kind == skArray32:
let lenBytes =
case kind
of skArray8: 1
of skArray16: 2
of skArray32: 4
else: unreachable("Invalid kind: " & $kind)

Update partially good news is that the error is actually intermitent, so some other memory corruption is happening... yay, I still don't know where though.

I'm currently hitting the unreachable unless I change the code to the above, and I must be blind because I don't see why that's happening.


The cgen seems fine:

cgen for or variant:

NU8* kind;
                      FR_.line = (NI64)898;
                      FR_.filename = "property_testing.nim";
                      kind = (&_107->Field1);
                      NIM_BOOL _108;
                      _108 = NIM_FALSE;
                      NIM_BOOL _109;
                      _109 = NIM_FALSE;
                      FR_.line = (NI64)899;
                      _109 = ((*kind) == (NU8)9);
                      NIM_BOOL _111;
                      _111 = !_109;
                      if (_111) {
                        _109 = ((*kind) == (NU8)10);
                      }
                      _108 = _109;
                      NIM_BOOL _113;
                      _113 = !_108;
                      if (_113) {
                        _108 = ((*kind) == (NU8)11);
                      }
                      if (_108) {

cgen for in variant:

                      kind = (&_107->Field1);
                      NIM_BOOL _109;
                      FR_.line = (NI64)899;
                      _109 = !(((NU16)3584 & ((NU16)1 << ((NU16)(*kind)))) == (NU16)0);
                      if (_109) {

Copy link
Copy Markdown
Collaborator

@zerbina zerbina left a comment

Choose a reason for hiding this comment

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

I did a first review pass, focusing on the things that can cause behaviour that looks like memory corruption.

Comment thread lib/experimental/property_testing.nim Outdated
Comment thread lib/experimental/property_testing.nim Outdated
Comment thread lib/experimental/property_testing.nim Outdated
let rMax = decodeUint64(buffer, pos, sBytes)
pos += sBytes
let rangeSize = if rMax > rMin: rMax - rMin else: 0'u64
pos += bytesForRange(rangeSize)
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.

Suggested change
pos += bytesForRange(rangeSize)
pos += bytesForRange(rMax - rMin)

If rMax < rMin (happens for signed integer ranges crossing zero), pos wouldn't be advanced properly when bytesForRange(rMax - rMin) != 1.

Comment thread lib/experimental/property_testing.nim Outdated
rMin = decodeUint64(buffer, pos + 2, sBytes)
rMax = decodeUint64(buffer, pos + 2 + sBytes, sBytes)
rangeSize = if rMax > rMin: rMax - rMin else: 0'u64
vBytes = bytesForRange(rangeSize)
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.

Suggested change
vBytes = bytesForRange(rangeSize)
vBytes = bytesForRange(rMax - rMin)

Same as above.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think with the conversion I have in place this should no longer be an issue.

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.

It doesn't cause any issues anymore, yea, but the rMax > rMin check is still unnecessary.

Comment thread lib/experimental/property_testing.nim Outdated
Comment thread lib/experimental/property_testing.nim Outdated
if kind in {skByte, sk2Bytes, sk4Bytes, sk8Bytes}:
let sBytes = getScalarBytes(kind)
if pos + 1 + sBytes <= buffer.len:
let
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.

Since the scalar reduction is identical to the one used by range reduction (and also has the same issue with yielding some candidates twice), could you factor the code out into a template?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I've pulled out the core of it into numberShrinker, I'll see if I can rework the code to make them more similar to pull a bit more in tomorrow. 🤞🏽

Comment thread lib/experimental/property_testing.nim Outdated
Comment thread lib/experimental/property_testing.nim Outdated
@saem
Copy link
Copy Markdown
Collaborator Author

saem commented Mar 28, 2026

Note to self: drop bool strings, they're not implemented and would be annoying to deal with, and the savings are questionable

saem added 9 commits March 28, 2026 18:00
- introduce `int64` `chooseRange`
- create a conversion function from `uint64` to `int64` that preserves order
- fix-up call sites and tests
focuses on `candidates` iterator, to avoid misreading the buffer
These should change from `doAssert` to `assert`

Also, consider allowing catchable assertions for facilitating testing.
Currently buggy, it seems `collectNodes` isn't gathering all the nodes

# MARK: Buffer Tree Tools

proc treeRepr*(buffer: seq[byte]): string =
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note to self: debug why this, and/or collectNodes, is not printing the tree properly in the failing test

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.

In case you didn't get to debug it yet, this is because the buffer doesn't start with an array or group node, meaning that collectNodes only yields the node itself (a range).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I got that visualized, I'm just trying to sort out where it's happening now:

# what collect nodes sees
@[(0, skRange)]

# the buffer; note `5` corresponds to `skRange` and it's missing group
@[5, 2, 1, 0, 0, 0, 10, 0, 0, 0, 1, 6, 2, 9, 2, 5, 3, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 5, 2, 1, 0, 0, 0, 100, 0, 0, 0, 0, 6, 0, 9, 2, 5, 3, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 5, 2, 1, 0, 0, 0, 100, 0, 0, 0, 0, 6, 1, 5, 0, 0, 255, 65]

# what my repr ends up outputing, because of what collect sees
skRange (kind: sk4Bytes, min: 1, max: 10, val: 1)

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.

The structure comes to be because in genSeq, there's a chooseRange call (for computing the length to use) before beginArray, adding a superfluous node to the tree.

The best way I can think of to address this is to add two more fields to the skArrayX nodes, one for the minimum and one for the maximum length. This would also fix shrinking being able to produce candidates violating the minimum length specification.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I've started reworking the API, I collapsed all arrays to one kind, skArray, and made the length an skRange. The beginArray API now takes a min and max and so the range should be created in the correct position, it always write the array kind first and it returns the chosen length. This has the side effect of making getting rid of a lot recording state checks being dropped from various callers.

Now I gotta debug various bits and bobs, such as minimum length violations and whatever bugs lie in my rework.

Might be better to keep the various byte width arrays but they're easy enough to introduce after, I believe. It would save me a range kind byte.

Since it creates an `skGroup` and that only takes a byte worth of elements this makes the API safer.

suggested by @zerbina
Comment thread lib/experimental/property_testing.nim Outdated
also renamed them to `renumerateXtoY` as renumerate means to renumber/recount, instead of conversion which is more ambiguous.
Comment thread lib/experimental/property_testing.nim Outdated
Comment thread lib/experimental/property_testing.nim Outdated
saem and others added 2 commits March 29, 2026 12:22
- work even if the input expression (`x`) has side-effects
- avoid conversions that would result in range check errors and potentially extra work

Co-authored-by: zerbina <100542850+zerbina@users.noreply.github.com>
need to fix `collectNodes` still
@saem saem marked this pull request as draft March 30, 2026 15:56
@saem
Copy link
Copy Markdown
Collaborator Author

saem commented Mar 30, 2026

I'm going to be pushing a lot of changes that are incremental as I rework the internals, so I've marked it as draft for now so as to not burn CI resources unnecessarily.

saem added 2 commits April 3, 2026 12:29
This test is the next failure to fix:
> candidates yields structurally valid byte sequences (Meta-test)

It shows that int -> uint range encoding is busted see the `treeRepr` output (confirmed by mentally converting the byte buffer as well):

> skRange (kind: sk8Bytes, min: 9223372036854774808, max: 9223372036854776808, offset: 1741, value: 9223372036854776549)

code is still ugly:
- need to create a single range/array unpack template
- array shouldn't have an skrange following it, just the kind, min, max, and offset
the array shrinking is more correct wherein `rMax and `rMin` aren't as far off in the final `skipToRangeOffsetAndGetSizeAndMin` template.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stdlib Standard library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants