Pure Random Numbers¶
pure_random is a random-number library with no hidden state. The
generator state is an ordinary term, rng(Seed, N): the seed you chose and
the number of draws taken so far. Every predicate is a relation from an input
state S0 to an output state S:
| Predicate | Mode | Answer |
|---|---|---|
rng_seed(Seed, S) |
+Seed, -S |
S = rng(Seed, 0), the initial state |
random(X, S0, S) |
-X, +S0, -S |
a float in [0, 1) |
random_between(L, H, X, S0, S) |
+L, +H, -X, +S0, -S |
an integer in [L, H], both ends included |
random_member(X, List, S0, S) |
-X, +List, +S0, -S |
an element of List |
random_permutation(List, Perm, S0, S) |
+List, -Perm, +S0, -S |
a permutation of List |
random_sample(List, K, Sample, S0, S) |
+List, +K, -Sample, +S0, -S |
K elements of List at distinct positions, in the order drawn |
The implementation lives in clausal/modules/pure_random.py.
Clausal Prolog and the seam
Examples on this page are Clausal Prolog
(.clausal), ISO Prolog syntax. The seam, Clausal's
Python-syntax surface, imports the library with -import_from instead
of use_module; Import shows both.
Why state-threaded¶
- Reproducible. The same seed gives the same answers on every run, in every process, on every platform, under every supported Python version (see the guarantee below).
- Backtrack-safe. The state is a binding, so backtracking restores it.
Re-running a goal from the same
S0gives the same answer. A global generator cannot do this: it moves on even when the goal that drew from it is undone. - Pure. No clock, no OS entropy, no global variable. A predicate's answer depends only on its arguments, so the library is safe to allow under a sandbox.
Want different answers on each run? Choose the seed outside the program, for example from the command line, and pass it in.
Import¶
From Clausal Prolog (.clausal) or a .pl file:
:- use_module(library(pure_random),
[rng_seed/2, random/3, random_between/5, random_member/4,
random_permutation/4, random_sample/5]).
From the seam:
-import_from(pure_random, [rng_seed, random, random_between, random_member,
random_permutation, random_sample])
Threading the state¶
Pass each output state on as the next input:
:- use_module(library(pure_random), [rng_seed/2, random_between/5]).
two_dice(A, B) :-
rng_seed(42, S0),
random_between(1, 6, A, S0, S1),
random_between(1, 6, B, S1, _).
test("the same seed gives the same rolls") :-
two_dice(A1, B1),
two_dice(A2, B2),
A1 == A2,
B1 == B2.
Backtracking restores the state, so both branches below draw the same number:
:- use_module(library(pure_random), [rng_seed/2, random_between/5]).
test("backtracking restores the state") :-
rng_seed(7, S0),
findall(X, ( random_between(1, 100, X, S0, _)
; random_between(1, 100, X, S0, _)
), [A, B]),
A == B.
With DCGs¶
The state is the last two arguments, which is where a grammar rule threads
its pair. So a DCG body threads the generator with no plumbing, and
phrase/3 runs it from a seed:
:- use_module(library(pure_random), [rng_seed/2, random_between/5]).
dice([X, Y]) --> random_between(1, 6, X), random_between(1, 6, Y).
roll(Xs) :-
rng_seed(1, S0),
phrase(dice(Xs), S0, _).
test("a grammar threads the state") :-
roll([X, Y]),
between(1, 6, X),
between(1, 6, Y).
(The seam writes the rule dice([X, Y]) >> (random_between(1, 6, X),
random_between(1, 6, Y)).)
Errors¶
A random predicate never fails silently.
| Case | Error |
|---|---|
S0 unbound, or rng(Seed, N) with an unbound part |
instantiation_error |
S0 bound but not rng(Seed, N) with a valid seed and an integer N >= 0 |
type_error(rng_state, S0) |
rng_seed/2: Seed unbound |
instantiation_error |
rng_seed/2: Seed not an integer, an atom or a string |
type_error(rng_seed, Seed) |
random_between/5: L or H unbound |
instantiation_error |
random_between/5: L or H not an integer |
type_error(integer, _) |
random_between/5: L > H (an empty range) |
domain_error(not_less_than(L), H) |
| A list argument that is partial | instantiation_error |
| A list argument that is not a list | type_error(list, _) |
random_member/4 on [] |
domain_error(non_empty_list, []) |
random_sample/5: K not an integer |
type_error(integer, K) |
random_sample/5: K < 0 |
domain_error(not_less_than_zero, K) |
random_sample/5: K greater than the list's length Len |
domain_error(not_greater_than(Len), K) |
An empty range and an empty list are errors and not failures. A draw from nothing is almost always a bug in the caller, and a failure would hide it.
Seeds¶
A seed is an integer, an atom or a string. An atom and a string seed by their
text, so abc and "abc" give the same stream. The integer 5 and the atom
'5' are different seeds. The seed is never hashed with Python's hash(),
which changes from one process to the next.
The reproducibility guarantee¶
The N-th draw from a seed is a fixed float:
- The seed is encoded as bytes: an integer as
ifollowed by its decimal digits, an atom or a string astfollowed by its text in UTF-8. The seed material is the SHA-256 digest ofclausal.pure_random.v1, a zero byte, the seed bytes, a zero byte and the decimal digits ofN, read as a big-endian integer. - The float is
random.Random(material).random(). Python promises thatrandom()gives the same sequence for the same seed on every version. Nothing else in Python'srandommodule is used.randint,randrange,choice,shuffleandsamplehave changed between Python versions. - Clausal's own rules turn draws into the other answers:
- each draw gives 53 random bits;
- an integer in
[L, H]is built from enough draws to cover the range, and rejection sampling keeps it unbiased; every call takes at least one draw; random_member/4takes the element at a random index;random_permutation/4is a Fisher–Yates shuffle from the end;random_sample/5runsKsteps of a Fisher–Yates shuffle from the front.
The output state is rng(Seed, N + D), where D is the number of draws the
call took. The tests in tests/test_pure_random.py pin fixed seeds to fixed
outputs, so any change to this mapping, or in Python, fails the tests.
Compared with Scryer's library(random)¶
Scryer Prolog's library(random) is impure: random/1,
random_integer/3 (the upper bound is excluded), set_random/1 and
maybe/0 share one global generator. This library keeps Scryer's
argument order and adds the two state arguments: random(X) becomes
random(X, S0, S). For integers it uses the name random_between, whose
upper bound is included, so random_integer never means two things.
The library is named library(pure_random). Scryer already has a
library(random), and a facade never takes the name of a Scryer library.
Importing Scryer's random/1 from library(pure_random) is an error that
lists what the library does export. It never binds to something else.
A Scryer-compatible impure layer (random/1, random_integer/3,
set_random/1) is not provided. It may be added if a program needs it.
The old py.random module¶
py.random (library(py_random)) draws from a process-global
generator. It is deprecated. The first time a program calls one of its
predicates, it warns once per process
(clausal.lint_warnings.ClausalPyRandomDeprecationWarning). It still works,
and will be removed in a later release.