hanki

gen

stdlib/core/gen.hk: the property-testing generator core.

A Gen<T> is a choice-sequence sampler (docs/design/quickcheck.md): it does not hold a random T, it is a recipe that reads primitive choices from a ChoiceSource and builds a T. Generation is pure: the source threads a seed; there is no [random] effect. The representation is choice-sequence from day one so the integrated shrinker can swap the PRNG source for a minimizing buffer behind the same ChoiceSource interface, with no change to any generator and no per-type shrink function.

This module is the core: Gen<T>, the seeded-PRNG ChoiceSource, and the map/bind/filter/list combinators. Arbitrary instances and @derive(Arbitrary) live elsewhere; so does the test-with-params runner.

Choice

struct Choice
  value: u64
  source: ChoiceSource
end

A primitive 64-bit choice drawn from a source, paired with the advanced source (Hanki threads state by value, and nothing mutates in place).

ChoiceSource

opaque ChoiceSource
  st: u64
  size: i32
end

The source of primitive choices. In generation mode it is a seeded LCG, pure and total, since fixed-width arithmetic wraps mod 2^64 (HANKI.md §3), so no bitwise ops are needed. The shrinking engine replaces it with a recorded, minimizing buffer behind the same next interface.

impl ChoiceSource

from_seed

def from_seed(seed: u64) -> ChoiceSource

A fresh source seeded by seed, at the default generation size. The runner derives the seed from the test name, or from --seed, and a run is therefore reproducible. @no-doctest: builds an opaque ChoiceSource with no literal form to assert; the tests below exercise it

next

def next(self) -> Choice

Draw the next raw choice and the advanced source. An LCG step (Knuth MMIX multiplier/increment); the wrap on overflow is the intended modular step. The size budget rides along unchanged. A transformation (the successor state) and no attribute, hence the @transform H0574 opt-out.

a = ChoiceSource.from_seed(1u64).next()
ChoiceSource.from_seed(1u64).next().value == a.value => true
a.source.size_hint => 30i32

size_hint

prop size_hint(self) -> i32

The generation size this source has.

ChoiceSource.from_seed(1u64).size_hint => 30i32

with_size

def with_size(self, n: i32) -> ChoiceSource

This source rebased to generation size n: the same PRNG position, a new size budget. Gen.scale / Gen.recur use it to shrink the size at a recursion point.

ChoiceSource.from_seed(1u64).with_size(5i32).size_hint => 5i32

Drawn

struct Drawn<T>
  value: Option<T>
  source: ChoiceSource
end

The outcome of one sampling: value is None for a rejected case, a filter whose predicate failed for its whole retry budget, which the runner discards and never counts as a pass or a failure. source is the source advanced past every choice the generator consumed.

Weighted

struct Weighted<T>
  weight: i32
  gen: Gen<T>
end

A weighted generator choice for Gen.frequency. weight is a relative likelihood (clamped to 0 if negative); gen is the generator drawn where this choice is selected. Hanki has no tuples, and a named pair therefore pairs the (weight, gen) pairing.

Gen

opaque Gen<T>
  run_fn: (ChoiceSource) -> Drawn<T>
end

An opaque generator. The internal run_fn representation is hidden so the shrinker can change how a source is consumed without breaking callers.

impl<T> Gen<T>

run

def run(self, src: ChoiceSource) -> Drawn<T>

Sample once against src, returning the value (or rejection) and the advanced source.

Gen.unit(7i32).run(ChoiceSource.from_seed(1u64)).value.unwrap_or(0i32) => 7i32

unit

def unit(x: T) -> Gen<T>

The generator that always yields x, consuming no choices.

Consuming no choices means the source it hands back still draws what the original would have:

s = ChoiceSource.from_seed(1u64)
Gen.unit(7i32).run(s).source.next().value == s.next().value => true

uint64

def uint64() -> Gen<u64>

A generator of raw 64-bit choices.

Pure: the same seed draws the same value, which is what makes a failing case replayable.

s = ChoiceSource.from_seed(9u64)
Gen.uint64().run(s).value == Gen.uint64().run(s).value => true

int_range

def int_range(lo: i32, hi: i32) -> Gen<i32>

A generator of i32 in the inclusive range [lo, hi].

v = Gen.int_range(3i32, 7i32).run(ChoiceSource.from_seed(1u64)).value.unwrap_or(0i32)
v >= 3i32 and v <= 7i32 => true

map

def map<U>(self, f: (T) -> U) -> Gen<U>

Transform every generated value through f. A rejection passes through.

Gen.unit(3i32).map(|x| x * 2i32).run(ChoiceSource.from_seed(1u64)).value.unwrap_or(0i32) => 6i32

bind

def bind<U>(self, f: (T) -> Gen<U>) -> Gen<U>

Sequence a dependent generator: draw a T, then run f(t) against the advanced source. A rejection short-circuits.

Gen.unit(3i32).bind(|x| Gen.unit(x + 1i32)).run(ChoiceSource.from_seed(1u64)).value.unwrap_or(0i32) => 4i32

filter

def filter(self, pred: (T) -> bool) -> Gen<T>

Keep only values satisfying pred. Re-draws on rejection up to a bounded number of times, then marks the case invalid (a None the runner discards) in place of looping forever.

A value the predicate rejects for its whole budget comes back as the rejection None, which the runner discards and never counts:

s = ChoiceSource.from_seed(1u64)
Gen.unit(4i32).filter(|x| x % 2i32 == 0i32).run(s).value.unwrap_or(0i32) => 4i32
Gen.unit(3i32).filter(|x| x % 2i32 == 0i32).run(s).value.unwrap_or(0i32) => 0i32

list

def list(self, min_len: int, max_len: int) -> Gen<List<T>>

A generator of lists whose length is drawn from [min_len, max_len], each element drawn from self. A rejected element rejects the whole list.

xs = Gen.unit(1i32).list(2, 4).run(ChoiceSource.from_seed(1u64)).value.unwrap_or(List.empty())
xs.length >= 2 and xs.length <= 4 => true

sized

def sized(f: (i32) -> Gen<T>) -> Gen<T>

Build a generator from the current generation size: reads the size the source has and hands it to f, which returns the generator to sample. Paired with recur it makes a recursive type terminate: Gen.sized(|n| if n <= 0i32 then base() else Gen.one_of(branches)), where each recursive branch shrinks the size with recur.

Gen.sized(|n| Gen.unit(n)).run(ChoiceSource.from_seed(1u64)).value.unwrap_or(0i32) => 30i32

scale

def scale(self, f: (i32) -> i32) -> Gen<T>

Sample self against a source whose generation size is f applied to the current size. The PRNG position is untouched; only the size budget changes.

Gen.sized(|n| Gen.unit(n)).scale(|n| n / 3i32).run(ChoiceSource.from_seed(1u64)).value.unwrap_or(0i32) => 10i32

recur

def recur(self) -> Gen<T>

Sample self at half the current generation size: the recursion step for a recursive generator, and the size budget therefore reaches a base case in a logarithmic number of levels and generation terminates. A transformation, a derived generator, and no attribute, hence @transform.

Gen.sized(|n| Gen.unit(n)).recur().run(ChoiceSource.from_seed(1u64)).value.unwrap_or(0i32) => 15i32

one_of

def one_of(gens: List<Gen<T>>) -> Gen<T>

Choose one of gens uniformly and sample it. An empty list yields a rejection (None). The building block for a sum type's generator: one_of over its variants' generators (recursive ones wrapped in recur).

v = Gen.one_of([Gen.unit(1i32), Gen.unit(2i32)]).run(ChoiceSource.from_seed(1u64)).value.unwrap_or(0i32)
v == 1i32 or v == 2i32 => true

frequency

def frequency(choices: List<Weighted<T>>) -> Gen<T>

Choose one of choices with probability proportional to its weight and sample it. Like one_of, and each generator takes an i32 weight; the pick uses the same monotone _bounded reduction over the total weight, and a smaller recorded choice therefore still selects an earlier entry. Keep choices base-first, and shrinking drives toward base cases as with one_of. An empty list, or a total weight <= 0 (every weight non-positive), rejects the case (None), mirroring one_of over an empty list. Negative weights are clamped to 0. The size-leaf-biased building block for a derived recursive sum: base variants at a constant weight, recursive ones weighted by the size budget so they grade out at the floor.

A weight of 0 is never selected, and the pick is therefore a real weighting and not than a shuffle:

only = [Weighted(weight=1i32, gen=Gen.unit(1i32)), Weighted(weight=0i32, gen=Gen.unit(2i32))]
Gen.frequency(only).run(ChoiceSource.from_seed(1u64)).value.unwrap_or(0i32) => 1i32

_bounded

def _bounded(u: u64, span: i32) -> i32

A value in [0, span) from a raw choice, by a monotone (Lemire) reduction: a larger u maps to a larger-or-equal result. That monotonicity is what lets the integrated shrinker minimise a wide scalar optimally: reducing the recorded choice toward zero reduces the value toward zero. A plain u % span is uniform and non-monotone, and it shrank scalars only best-effort. The reduction multiplies the high 32 bits of u by span and takes the top 32 bits of that 64-bit product (floor(hi * span / 2^32)), staying within 64-bit arithmetic (no i128) and near-uniform. span <= 1 (degenerate or empty ranges) yields 0, which also dodges the product overflowing on a non-positive span. Division is by non-zero literals, and the bare fixed-width / is therefore allowed (HANKI.md §3).

sumweights

def _sum_weights<T>(choices: List<Weighted<T>>, idx: int) -> i32

Total of choices' weights from idx on, each clamped to >= 0, the denominator for the frequency pick. A total of 0 (empty or all non-positive) makes the pick reject.

pickweighted

def _pick_weighted<T>(choices: List<Weighted<T>>, idx: int, point: i32, acc: i32, src: ChoiceSource) -> Drawn<T>

Walk choices from idx, accumulating clamped weights, and run the first generator whose cumulative bracket [acc, acc + weight) contains point (a value in [0, total)). A zero/negative-weight entry has an empty bracket and is never selected. Walking off the end, on an empty list or every weight non-positive, rejects with None.

filterloop

def _filter_loop<T>(g: Gen<T>, pred: (T) -> bool, src: ChoiceSource, fuel: i32) -> Drawn<T>

filterkeep

def _filter_keep<T>(g: Gen<T>, pred: (T) -> bool, x: T, src: ChoiceSource, fuel: i32) -> Drawn<T>

drawlist

def _draw_list<T>(g: Gen<T>, n: int, src: ChoiceSource, acc: List<T>) -> Drawn<List<T>>

seeded_source

def seeded_source(seed: u64) -> ChoiceSource

A choice source seeded by seed. A free function so a fully-qualified gen.seeded_source(s) resolves where a qualified associated fn on the opaque ChoiceSource would not.

seeded_source(1u64).size_hint => 30i32

runpropertycases!

def run_property_cases!(n: i32, src: ChoiceSource, f: (ChoiceSource) -> ChoiceSource [e]) -> () [e]

Run f for n property cases, threading the choice source through each. f samples the property's parameters, runs its body, and returns the advanced source; its effect row [e] is whatever the body performs.

@no-doctest: drives n cases through a caller-supplied body, and what it does is whatever that body does; the test below observes the case count