sysl

The rand module

sysl.rand — PCG32, seeded by the caller and reproducible; a bounded integer with no modulo bias, a shuffle that is Fisher-Yates, and OS seeding kept in a module of its own so a freestanding target need never import it.

sysl.rand is a named, seedable, reproducible pseudo-random generator and the distributions that are easy to get wrong.

This is not a source of unpredictability, and nothing here should be used as one. The generator is completely determined by its seed — which is exactly what a test that must reproduce, a simulation that must replay, and a shuffle all want, and exactly what a key, a token, a nonce or a password reset must not have. Anyone who can see a handful of outputs can compute the state and every output that follows. A cryptographic generator is a different thing with a different implementation, and it is not in this module under any name.

import sysl.rand.rng

var g = rng(42, 54)

print(g.below(6), g.below(6), g.below(6))
print(g.boolean())
3 3 2
true

Those numbers are not illustrative — they are what that seed produces, every run, on every machine. That is the whole point of the module.

The algorithm is named so it can be checked

It is PCG32 — PCG-XSH-RR with a 64-bit state and 32-bit output, from M. E. O’Neill’s PCG: A Family of Simple Fast Space-Efficient Statistically Good Algorithms for Random Number Generation (2014). The library’s own tests pin it against the reference implementation’s first outputs, because a generator nobody can identify is one nobody can verify.

The state advances by an ordinary linear congruential step, and the output is a permutation of the state’s high bits rather than the state itself. That second step is what a bare congruential generator lacks, and it is why its low bits are notoriously poor — here every bit of the output is a product of the whole state.

Seeding is the caller’s, and that is what keeps it portable

rng(seed, seq) takes two arguments because the stream is not the seed. Two generators with the same seed and different seq produce different, equally good sequences — which is what a program wanting several independent streams from one seed needs, and what seeding from consecutive seeds does not reliably give.

import sysl.rand.rng

var a = rng(7, 1)
var b = rng(7, 1)
var c = rng(7, 2)

print(a.next_u32() == b.next_u32())
print(a.next_u32() == c.next_u32())
true
false

Nothing in sysl.rand reads a clock or asks the operating system for anything, so it compiles and runs on a freestanding target.

Taking a seed from the host — sysl.posix.rand

A program that wants a different sequence each run needs entropy, and that needs an operating system. It is a module of its own so that importing the generator cannot drag one in behind it — the same split sysl.term and sysl.posix.tty already make.

It sits under sysl.posix rather than under sysl.rand because getentropy(2) is what it is, and that is the rule the whole namespace follows: a module there is one a freestanding target does not get, and the path says so without the file having to be opened.

import sysl.rand.rng
import sysl.posix.rand.seed_from_os

var g = rng(seed_from_os().unwrap_or(0), 1)

print(g.below(100) < 100)
true

It answers an Option rather than trapping: a program that cannot get entropy usually has a reasonable fallback — a fixed seed and a line in its log saying so — and a library that aborted would take that choice away. It requires posix.

below is where this module earns its keep

The obvious next_u64() % n is not uniform whenever n does not divide 2⁶⁴: the low residues come up more often, by a margin that is invisible in a handful of draws and is a real defect in a simulation or a shuffle. below rejects the unfair tail of the range instead, which is exact, terminates with probability one, and expects under two draws.

import sysl.rand.rng

var g = rng(99, 3)
var counts: []int = [0; 6]

for i in 0..<6000
    counts[usize(g.below(6))] += 1

print(counts[0] > 850 && counts[0] < 1150)
print(counts[5] > 850 && counts[5] < 1150)
true
true

range(lo, hi) is the same thing over a signed interval, half-open at the top. unit() answers a real in [0, 1) from 53 bits — the width of a real‘s mantissa, so no representable value is unreachable and none is thrown away — and never returns 1.0, which is the property every caller scaling by a range depends on.

Shuffling

import sysl.rand.rng

var g = rng(2024, 1)
var xs = [1, 2, 3, 4, 5]

g.shuffle(xs)

print(xs.len)
5

Fisher-Yates, walking down: each position from the end takes a uniformly chosen element from those at or below it. The whole difficulty is drawing that index without bias, which is below‘s job — the loop itself is three lines.

It lives here rather than in sysl.slices because it is a fact about a generator, and because sysl.slices is the module a C binding reaches into: acquiring a random-number generator by asking for a slice operation would be a poor trade.

Search

Esc
to navigate to open Esc to close