← Math in Practice
Concept Change, uncertainty, and evidence

Randomness and random number generators

First decide what randomness must do: repeat for diagnosis, or resist prediction.

An experiment gives a discount to one account from a list. The first run looks fair. A support ticket reports that two accounts got the same “unique” invitation code, and a test fails only on the engineer’s machine. Those symptoms may share a word—random—but they ask different questions about the generator, the range, and the algorithm around it.

The judgment to keep

Choose a generator for the job, then inspect the transformation applied to its output. A cryptographic source cannot rescue a biased range mapping; a reproducible seed cannot make a secret unpredictable.

TypeScriptGo Seeded pseudorandomness · cryptographic randomness · bounded sampling · modulo bias · Fisher–Yates shuffle
01 / Read the incident

“We used a random number” is not yet an explanation.

A product team is selecting one of eight accounts for a small promotional credit. Their helper asks a random-number API for a value from 0 through 7, then uses that value as an array index. A unit test passes locally and fails in CI because the selected account differs. Separately, a security review asks whether the same helper can issue invitation tokens that outsiders cannot guess.

The test’s changing result can be normal: a randomized test has no fixed sequence unless its inputs are controlled. The token question is different: a value that is merely hard to predict by a test runner may still be guessable by an attacker. We should trace the path from source to decision instead of treating “random” as a single property.

Case file / Promotion picker What does the product actually require?
Decision
Choose one of eight eligible accounts.
Observed issue
A test expects a repeatable result; its current draw changes.
Second use
Generate an invitation token that grants access when redeemed.
Unknowns
Generator, seed, range mapping, token lifetime, and retry limits.
Diagnostic pause: before changing code, record the required property: repeatable, uniform over a finite set, unpredictable to an attacker, or some combination.
02 / Name the kind of random

A seed controls a sequence; it does not add secrecy.

A pseudorandom number generator (PRNG) is an algorithm with internal state. Given the same algorithm and initial seed, it produces the same sequence. This makes a seeded PRNG useful for reproducing a randomized test: save the seed with the failure and replay the sequence while you inspect which branch was taken. The sequence can look irregular while still being completely determined by its state.

A cryptographically secure pseudorandom number generator (CSPRNG) is designed so outputs remain computationally difficult to predict without secret state, even when an attacker sees other outputs. Operating systems expose such a source through platform APIs. Application code should use the approved API rather than inventing an entropy source from the clock, process ID, a counter, or a general-purpose seeded PRNG.

This distinction is about purpose, not visual quality. A test may need an exact replay more than it needs unpredictability. A password reset token needs unpredictability and enough possible values that online guesses are infeasible under the system’s controls. A randomized test can also run multiple seeds in CI, but it should print and retain the seed that reproduces any failure.

Match the generator to the property the work needs
TaskUseful propertyEvidence to keep
Reproduce a randomized testSame seed and algorithm repeat the sequence.Seed, generator version, test inputs, and failing step.
Simulate a modelControlled streams support comparable runs.Seed, model assumptions, and summary across many runs.
Issue a reset tokenUnpredictability against an attacker.Platform CSPRNG API, token handling, and access controls.
Pick a winnerUniform selection over eligible entries, with auditability.Eligibility snapshot, mapping method, and any required audit record.
03 / Inspect the bounded draw

Remainder mapping can make some outcomes more common.

Suppose a toy source can return each integer from 0 through 7 with equal probability. The team wants one of three outcomes and writes source % 3. Eight equally likely inputs do not split evenly into three groups: residues 0 and 1 each have three inputs; residue 2 has two. The first two outcomes are therefore each selected with probability 3/8, while the third is selected with probability 2/8. This is modulo bias.

The diagnosis is a counting argument. If a source has N equally likely values and we want k outcomes, then simple remainder mapping is even only when N is divisible by k. Keep the largest prefix whose size is divisible by the bound: limit = floor(N / k) × k. Reject source values at or above that limit; each remaining residue then has exactly limit / k preimages.

For the toy 0–7 source and three outcomes, limit = floor(8 / 3) × 3 = 6. Discard 6 and 7, then apply remainder to 0–5. Each outcome appears exactly twice. Real library helpers use this kind of rejection logic or an equivalent method, so prefer a vetted bounded-random API when one is available. Never assume `% bound` makes a range uniform.

Keep the question empirical: identify the raw range, count how many raw values map to each result, and inspect the actual bounded API before blaming the generator.
04 / Check the shuffle

A fair shuffle needs one fair bounded choice at each step.

A common mistake is to visit every position and swap it with any position in the whole array. That does not produce every permutation with the same probability. For three elements, there are 3³ = 27 possible sequences of choices, while there are only 3! = 6 permutations; 27 cannot divide evenly by 6, so some orders necessarily arise more often.

The Fisher–Yates algorithm fixes the choice set as the work shrinks. Starting at the last index i, choose j uniformly from 0…i, swap those entries, then move to i − 1. At each step, the selected item is fixed into its final position. For n items there are n × (n−1) × … × 1 = n! possible choice paths, and each path corresponds to one permutation. If each bounded choice is uniform and independent, each permutation has probability 1 / n!.

For a three-item list [A, B, C], choose an index in 0…2 and place that item last; then choose in 0…1 and place that item next-to-last. The final remaining item is fixed. The choice ranges shrink with the unplaced prefix; this is why a uniform bounded sampler belongs inside the algorithm.

Trace / Fisher–YatesShuffle [A, B, C] using two bounded draws.
Draw 1
Choose j in 0…2; swap the chosen item with index 2.
Draw 2
Choose j in 0…1; swap the chosen item with index 1.
Finish
Index 0 is the last remaining item; no draw is needed.
Fairness condition
Each draw is uniform over its current inclusive range.
05 / See the bias

Change the source size and count how many raw values each outcome receives.

This finite-source lab counts possible inputs exactly. It is not a random simulation and does not measure a real generator. A small source makes the mapping visible: compare all raw values under remainder mapping with the accepted prefix used by rejection sampling.

Finite-source lab / Bounded outcomes All raw values are equally likely in this model.
Naive remainder counts3 · 3 · 2

Count of raw values assigned to outcomes 0 through 2.

Rejection counts2 · 2 · 2

Accept raw values below 6; discard 2 value(s), then take remainder.

Formula: accepted limit = floor(N ÷ k) × k = 2 × 3 = 6. Each accepted outcome has 2 raw preimage(s).

06 / Practice in code

Use secure bytes, remove the incomplete tail, then run Fisher–Yates.

The examples use the operating system's cryptographic random source for a bounded integer and a shuffle. The TypeScript helper draws a 32-bit word and rejects the incomplete tail before taking a remainder. The Go example delegates bounded selection to crypto/rand.Int, which returns a value in [0, bound). Both validate the bound and propagate failure. A security-sensitive caller must handle source errors; it should not silently fall back to a seeded generator.

The PRNG snippet is deliberately separate and only demonstrates deterministic replay. Keep its state and output away from secrets. For robust application code, prefer standard-library seeded generators for simulation unless cross-version sequence stability is part of the contract; if it is, specify and version the generator explicitly.

Compare secure bounded sampling and Fisher–Yates in TypeScript and Go.

Trace the accepted range, the inclusive shuffle bound, and the error path.

TypeScriptCryptographic bounded draws and unbiased Fisher–Yates shuffle
randomness.ts
/**
 * Draw an unbiased integer in [0, bound) from browser cryptographic randomness.
 * Rejection removes the incomplete tail that would make `% bound` biased.
 */
export function secureBelow(bound: number): number {
	const range = 2 ** 32;
	if (!Number.isSafeInteger(bound) || bound < 1 || bound > range) {
		throw new RangeError('bound must be an integer from 1 through 2^32');
	}
	const limit = Math.floor(range / bound) * bound;
	const word = new Uint32Array(1);
	let value: number;
	do {
		globalThis.crypto.getRandomValues(word);
		value = word[0]!;
	} while (value >= limit);
	return value % bound;
}

/** Fisher–Yates using cryptographically strong, unbiased bounded draws. */
export function secureShuffle<T>(items: readonly T[]): T[] {
	const result = [...items];
	for (let i = result.length - 1; i > 0; i -= 1) {
		const j = secureBelow(i + 1);
		[result[i], result[j]] = [result[j]!, result[i]!];
	}
	return result;
}

/**
 * A tiny deterministic source for replayable examples only. This is not a
 * cryptographic generator. A zero seed is rejected because xorshift would
 * remain at zero forever.
 */
export function xorshift32(seed: number): () => number {
	if (!Number.isInteger(seed) || seed < 1 || seed > 0xffff_ffff) {
		throw new RangeError('seed must be a non-zero uint32');
	}
	let state = seed >>> 0;
	return () => {
		state ^= state << 13;
		state ^= state >>> 17;
		state ^= state << 5;
		return state >>> 0;
	};
}

const draw = xorshift32(42);
console.log('Replayable example; never use for secrets:', [draw(), draw(), draw()]);
GoCryptographic bounded draws and unbiased Fisher–Yates shuffle
randomness.go
package main

import (
	"crypto/rand"
	"fmt"
	"math/big"
)

// secureBelow returns an unbiased integer in [0, bound). crypto/rand.Int
// uses rejection sampling so a non-dividing bound does not inherit modulo bias.
func secureBelow(bound int) (int, error) {
	if bound < 1 {
		return 0, fmt.Errorf("bound must be positive")
	}
	n, err := rand.Int(rand.Reader, big.NewInt(int64(bound)))
	if err != nil {
		return 0, fmt.Errorf("secure random draw: %w", err)
	}
	return int(n.Int64()), nil
}

// secureShuffle applies Fisher–Yates with one uniform bounded draw per step.
func secureShuffle(items []string) error {
	for i := len(items) - 1; i > 0; i-- {
		j, err := secureBelow(i + 1)
		if err != nil {
			return err
		}
		items[i], items[j] = items[j], items[i]
	}
	return nil
}

// xorshift32 is a tiny deterministic source for replayable examples only.
// It is not cryptographically secure. A non-zero seed is required.
func xorshift32(seed uint32) func() uint32 {
	if seed == 0 {
		panic("xorshift32 seed must be non-zero")
	}
	state := seed
	return func() uint32 {
		state ^= state << 13
		state ^= state >> 17
		state ^= state << 5
		return state
	}
}

func main() {
	items := []string{"alpha", "bravo", "charlie", "delta"}
	if err := secureShuffle(items); err != nil {
		// A security-sensitive path should surface the error; do not fall back
		// to a predictable generator.
		panic(err)
	}
	fmt.Println("Secure shuffle:", items)

	draw := xorshift32(42)
	fmt.Println("Replayable example; never use for secrets:", draw(), draw(), draw())
}
07 / Choose evidence and controls

Diagnose the whole path before deciding the random source is at fault.

When a randomized behavior surprises you, gather evidence in this order:

  1. State the requirement. Is the need replayability, statistical behavior, secrecy, or uniqueness?
  2. Record the source. Which API and algorithm ran, with what seed or OS source, and did errors propagate?
  3. Trace the mapping. What is the raw range, requested interval, rejection rule, and endpoint convention?
  4. Inspect the surrounding system. Are draws concurrent, retried, truncated, logged, persisted, or checked for collisions?
  5. Test the claim that could fail. Reproduce from a saved seed for tests; review code and threat controls for secrets; use statistical checks as diagnostics, not proof.
Transfer exercise

A playlist randomizer repeats the same opening song.

List at least three plausible causes before proposing a fix. How would you distinguish a seed-reset bug, a biased shuffle, a small playlist, and a product rule that avoids recent songs? What would you log or simulate, and which requirement—replayability, uniformity, or security—does the playlist actually need?