Source code for modelly.core.rng

"""A faster ``random.Random`` with the standard library's exact streams.

``FastRandom`` is a compiled core (``modelly._native.fastrng``, C on CPython's own
Mersenne Twister state) when it is built for this interpreter and passes a
stream self-test at import, and the pure-Python ``PyFastRandom`` otherwise.
``MODELLY_PURE_PYTHON=1`` forces the Python version. Both give, for a seed,
exactly the numbers ``random.Random`` gives.

``random.Random`` implements ``randint``, ``choice``, ``shuffle`` and ``sample``
in Python, and each draw goes through ``_randbelow`` as a separate method
call plus argument validation. In an agent-based model those four calls run
millions of times per experiment. ``FastRandom`` reimplements them with the
rejection loop written inline, making the same ``getrandbits`` calls in the
same order, so for a given seed it produces exactly the same numbers as
``random.Random``; a run seeded with either is reproducible with the other.
"""

from __future__ import annotations

import os
import random
from collections.abc import MutableSequence, Sequence
from math import ceil, log
from operator import index
from typing import Any

SMALL_SET_SIZE = 21  # size of a small set minus size of an empty list (stdlib)
SAMPLE_SET_THRESHOLD = 5  # stdlib switches to a selection set above this k

# n.bit_length() for every n below BITS_TABLE_SIZE. A tuple subscript is cheaper
# than the method call, and the ranges an ABM draws from are almost all small.
BITS_TABLE_SIZE = 4096
_BITS = tuple(n.bit_length() for n in range(BITS_TABLE_SIZE))
_SEQUENCE_TYPES = frozenset((list, tuple, range))
# Above this length shuffle walks the list in runs of constant bit length.
SEGMENTED_SHUFFLE_MIN = 64


def _rebuild(state: tuple) -> random.Random:
    """A ``FastRandom`` at ``state``: compiled or pure, whichever this process runs."""
    rng = FastRandom()
    rng.setstate(state)
    return rng


def _reduce(rng: random.Random) -> tuple:
    """Pickle either ``FastRandom`` as the other can load it; subclasses as usual."""
    if type(rng) in (FastRandom, PyFastRandom):
        return _rebuild, (rng.getstate(),)
    return random.Random.__reduce__(rng)


[docs] class PyFastRandom(random.Random): """``random.Random`` with the hot draws inlined in Python; identical streams. The fallback for ``FastRandom`` where the compiled core is unavailable. Both versions pickle to the same state, so a pickle written with one loads into the other and continues the same stream. """ __reduce__ = _reduce
[docs] def randint(self, a: int, b: int) -> int: """Return a random integer in [a, b], like ``random.randint``.""" # ``type(x) is int`` is specialised by the interpreter; ``index`` is a call. if type(a) is not int or type(b) is not int: a = index(a) b = index(b) n = b - a + 1 if 0 < n < BITS_TABLE_SIZE: k = _BITS[n] elif n <= 0: raise ValueError(f"empty range in randint({a}, {b})") else: k = n.bit_length() r = self.getrandbits(k) while r >= n: r = self.getrandbits(k) return a + r
[docs] def choice(self, seq: Sequence[Any]) -> Any: """Return a random element of ``seq``, like ``random.choice``.""" n = len(seq) if not n: raise IndexError("Cannot choose from an empty sequence") k = _BITS[n] if n < BITS_TABLE_SIZE else n.bit_length() r = self.getrandbits(k) while r >= n: r = self.getrandbits(k) return seq[r]
[docs] def shuffle(self, x: MutableSequence[Any]) -> None: """Shuffle ``x`` in place, like ``random.shuffle`` (same stream).""" if len(x) > SEGMENTED_SHUFFLE_MIN: self._shuffle_segmented(x) return getrandbits = self.getrandbits bits = _BITS for i in range(len(x) - 1, 0, -1): n = i + 1 k = bits[n] if n < BITS_TABLE_SIZE else n.bit_length() j = getrandbits(k) while j >= n: j = getrandbits(k) x[i], x[j] = x[j], x[i]
def _shuffle_segmented(self, x: MutableSequence[Any]) -> None: """The Fisher-Yates loop of ``shuffle``, one run per bit length. Every ``n`` in ``[2**(k-1), 2**k - 1]`` has bit length ``k``, so within a run the draw width is constant and the per-element lookup goes away. The draws are the same ``getrandbits(k)`` calls in the same order. """ getrandbits = self.getrandbits i = len(x) - 1 while i > 0: k = (i + 1).bit_length() low = max(1 << (k - 1), 2) - 1 # smallest i of this run for m in range(i, low - 1, -1): n = m + 1 j = getrandbits(k) while j >= n: j = getrandbits(k) x[m], x[j] = x[j], x[m] i = low - 1
[docs] def sample(self, population: Sequence[Any], k: int, *, counts=None) -> list: """Return ``k`` unique elements of ``population``, like ``random.sample``.""" if counts is not None: return super().sample(population, k, counts=counts) # The ABC check costs several hundred ns; the common types skip it. if type(population) not in _SEQUENCE_TYPES and not isinstance( population, Sequence ): raise TypeError( "Population must be a sequence. For dicts or sets, use sorted(d)." ) n = len(population) if not 0 <= k <= n: raise ValueError("Sample larger than population or is negative") setsize = SMALL_SET_SIZE if k > SAMPLE_SET_THRESHOLD: setsize += 4 ** ceil(log(k * 3, 4)) if n <= setsize: return self._sample_from_pool(population, n, k) return self._sample_by_rejection(population, n, k)
def _sample_from_pool(self, population: Sequence[Any], n: int, k: int) -> list: """``sample`` for a small population: draw from a shrinking copy.""" getrandbits = self.getrandbits result = [None] * k pool = list(population) for i in range(k): m = n - i kb = _BITS[m] if m < BITS_TABLE_SIZE else m.bit_length() j = getrandbits(kb) while j >= m: j = getrandbits(kb) result[i] = pool[j] pool[j] = pool[m - 1] return result def _sample_by_rejection(self, population: Sequence[Any], n: int, k: int) -> list: """``sample`` for a large population: redraw indices already selected.""" getrandbits = self.getrandbits result = [None] * k selected: set[int] = set() kb = _BITS[n] if n < BITS_TABLE_SIZE else n.bit_length() for i in range(k): j = getrandbits(kb) while j >= n or j in selected: j = getrandbits(kb) selected.add(j) result[i] = population[j] return result
def _matches_stdlib(cls: type) -> bool: """Whether ``cls`` reproduces ``random.Random`` draw for draw on a mixed sequence.""" reference, candidate = random.Random(20261023), cls(20261023) items = list(range(3000)) for step in range(400): size = step % 97 left, right = list(range(size)), list(range(size)) reference.shuffle(left) candidate.shuffle(right) draws = ( (reference.randint(0, step + 5), candidate.randint(0, step + 5)), (reference.choice(items), candidate.choice(items)), (reference.sample(items, step % 9), candidate.sample(items, step % 9)), (reference.random(), candidate.random()), (left, right), ) if any(a != b for a, b in draws): return False return reference.getstate() == candidate.getstate() def _load_native() -> Any: """The compiled core module, or None when absent, disabled or not exact.""" if os.environ.get("MODELLY_PURE_PYTHON"): return None try: from modelly._native import fastrng # noqa: PLC0415 - optional extension except ImportError: return None fastrng.set_fallbacks( random.Random.randint, random.Random.choice, random.Random.shuffle, random.Random._randbelow_with_getrandbits, random.Random.sample, ) return fastrng _fastrng = _load_native() if _fastrng is not None:
[docs] class FastRandom(_fastrng.Core, random.Random): """``random.Random`` with its hot draws in C; identical streams. ``randint``, ``choice``, ``shuffle``, ``sample`` and ``_randbelow`` run in C on the same Mersenne Twister state as the rest of ``random.Random``. """ __reduce__ = _reduce
if not _matches_stdlib(FastRandom): FastRandom = PyFastRandom # type: ignore[misc] else: FastRandom = PyFastRandom # type: ignore[misc] NATIVE: bool = FastRandom is not PyFastRandom