prelude.Rng
Part of prelude.
struct Rng
--- Seeded, reproducible random numbers (issue #20) ---
Float.random reads a process-global generator seeded from the clock, which is right for "give me something different each run" and useless for a test or a replay. An Rng is a VALUE: same seed, same sequence, on every platform and every run.
Every call returns the NEXT GENERATOR alongside the number, rather than mutating in place, so a generator is as ordinary a value as an Int. Threading it by hand is the cost; being able to replay a game from a seed, or write a test that cannot flake, is what it buys. A Ref[Rng] is the opt-in for in-place update.
--- Which generator, and why not a better one ---
This is L'Ecuyer's combined multiplicative generator (1988): two Lehmer streams with different moduli, subtracted. Period is about 2^61, which is ample for games and tests.
It is NOT the generator anyone would reach for first. The modern answers (PCG, xoshiro, splitmix64) all need wrapping 64-bit multiplication and bitwise xor/shift, and Plum has NEITHER: * traps on overflow, and there is no ^, & or << token in the language at all. What is left is arithmetic that stays inside an Int by construction, and among those this is the best understood. Both streams' largest intermediate is about 8.6e13, comfortably inside i64's 9.2e18.
Not for cryptography, keys, tokens or passwords. It is a statistical generator and its entire state is recoverable from two outputs.
let Rng.from_seed (seed: Int): Rng
Any Int is a legal seed. It is folded into the two streams' legal ranges (each must be non-zero and below its own modulus), so no caller has to know what those ranges are, including Rng.from_seed(0), which a naive mapping would turn into a generator that only ever returns one number.
let Rng.float (r: Rng): (Rng, Float)
A Float in [0.0, 1.0). The upper bound is excluded, which is what makes lo + (hi - lo) * f stay below hi.
let Rng.float_range (r: Rng) (lo: Float) (hi: Float): (Rng, Float)
A float in [lo, hi), and the next generator.
let Rng.int_range (r: Rng) (lo: Int) (hi: Int): (Rng, Int)
[lo, hi), the upper bound EXCLUDED, so int_range(r, 0, xs.len()) is an index into xs and needs no - 1 anywhere.
Rejection sampling, not z % span. The modulo is biased whenever the span does not divide the generator's range evenly: the low values come up more often, by about one part in range / span. That is invisible in a game and fatal in a shuffle, and the loop costs an extra step only on the fraction of draws that land in the leftover tail.
An empty or backwards range returns lo and does not draw, because there is no number to return and a panic here would make an ordinary off-by-one crash a program at random.
let Rng.bool (r: Rng): (Rng, Bool)
A boolean, evenly, and the next generator.
let Rng.shuffle (r: Rng) (xs: Array[T]): (Rng, Array[T])
Fisher-Yates, walking from the end so every permutation is equally likely. The naive version, swapping each element with any other, is not uniform, and looks fine until somebody counts.
let Rng.choice (r: Rng) (xs: Array[T]): (Rng, Option[T])
One element, or None from an empty array.