Design: random¶
Why the library ships its own seed()¶
Numba's nopython RNG state is per-thread and completely separate from
NumPy's Python-level state: np.random.seed(...) called from Python
does not affect jitted code, which is one of the most common
reproducibility surprises in Numba. numba_utils.random.seed is a
jitted function that seeds the state jitted code actually uses;
testing.deterministic_rng goes further and pins all three worlds
(NumPy legacy, NumPy Generator, Numba) in one call.
Why alias_setup and alias_draw are separate¶
The Walker alias method is O(n) preprocessing + O(1) per draw — that
split IS the algorithm's value. A merged alias_sample(weights, size)
would silently turn repeated sampling into O(n) per call, destroying
the property users chose the method for. The API mirrors the cost
model: pay setup once, hold the (prob, alias) tables, draw for free.
alias_sample(prob, alias, size) exists only as a bulk-draw
convenience over already-built tables.
Why weighted_sampling exists alongside the alias method¶
Different amortization regimes. weighted_sampling (cumulative sums +
binary search, O(n + size·log n)) wins for one-shot sampling or weights
that change between calls — no tables to build or hold. The alias
method wins when many draws share fixed weights. Both docstrings point
at each other; BENCHMARKS.md shows the crossover (weighted_sampling at
parity with NumPy, alias 2.7x with setup amortized).
Why reservoir sampling (Algorithm R)¶
Without-replacement sampling that needs one pass, O(k) memory, and no
shuffle of the input — the right primitive when n is huge or streaming.
For with-replacement, choice is the direct O(size) answer. The two
cover both replacement regimes with the minimal API for each.
Why a counter-based generator (Philox), and why 4x64-10¶
A stateful global RNG makes Monte Carlo results depend on execution
order: how many chunks ran, on how many threads, in what sequence —
the classic "cold worker gives a different answer" artifact. A
counter-based generator removes the state entirely:
philox_uniform(key, counter) is a pure function, so assigning each
work unit its own counter range makes the run reproducible by
construction, on any thread count.
Philox4x64-10 specifically because it is the exact algorithm behind
np.random.Philox — which gives the implementation something rare for
an RNG: an installable, independent reference to assert bit
equality against, in CI, forever (the test accounts for NumPy
incrementing the counter before generating its first block). The
64-bit multiply-high has no native uint128 in nopython, so it is
composed from 32-bit limbs.
philox_randint bounds by multiply-shift: the bias is below
n / 2**64 — documented rather than hidden, and negligible against
any Monte Carlo variance.
Why partial Fisher–Yates next to reservoir sampling¶
reservoir_sampling is one pass over ALL n with n random draws — the
streaming primitive. The "deal k cards from a materialized deck" loop
wants the opposite trade: partial_shuffle does exactly k swaps and k
draws on a scratch array the caller reuses, zero allocation per
iteration. Same task, different regime; each docstring points at the
other.