Deterministic RNG helpers.
"""Deterministic RNG helpers."""
from __future__ import annotations
import hashlib
import random
import struct
from typing import Any, cast
from cachetools import cached # type: ignore[import-untyped]
from .constants import DEFAULTS, get_param
from .locking import get_lock
from .types import GraphLike, TNFRGraph
from .utils import ScopedCounterCache, _SeedHashCache, build_cache_manager, get_graph
MASK64 = 0xFFFFFFFFFFFFFFFF
_RNG_LOCK = get_lock("rng")
_DEFAULT_CACHE_MAXSIZE = int(DEFAULTS.get("JITTER_CACHE_SIZE", 128))
_CACHE_MAXSIZE = _DEFAULT_CACHE_MAXSIZE
_CACHE_LOCKED = False
_RNG_CACHE_MANAGER = build_cache_manager(default_capacity=_DEFAULT_CACHE_MAXSIZE)
_seed_hash_cache = _SeedHashCache(
manager=_RNG_CACHE_MANAGER,
default_maxsize=_DEFAULT_CACHE_MAXSIZE,
)
def _compute_seed_hash(seed_int: int, key_int: int) -> int:
seed_bytes = struct.pack(
">QQ",
seed_int & MASK64,
key_int & MASK64,
)
return int.from_bytes(hashlib.blake2b(seed_bytes, digest_size=8).digest(), "big")
@cached(cache=_seed_hash_cache, lock=_RNG_LOCK)
def _cached_seed_hash(seed_int: int, key_int: int) -> int:
return _compute_seed_hash(seed_int, key_int)
def seed_hash(seed_int: int, key_int: int) -> int:
"""Return a 64-bit hash derived from ``seed_int`` and ``key_int``."""
if _CACHE_MAXSIZE <= 0 or not _seed_hash_cache.enabled:
return _compute_seed_hash(seed_int, key_int)
return _cached_seed_hash(seed_int, key_int)
seed_hash.cache_clear = cast(Any, _cached_seed_hash).cache_clear # type: ignore[attr-defined]
seed_hash.cache = _seed_hash_cache # type: ignore[attr-defined]
def _sync_cache_size(G: TNFRGraph | GraphLike | None) -> None:
"""Synchronise cache size with ``G`` when needed."""
global _CACHE_MAXSIZE
if G is None or _CACHE_LOCKED:
return
size = get_cache_maxsize(G)
with _RNG_LOCK:
if size != _seed_hash_cache.maxsize:
_seed_hash_cache.configure(size)
_CACHE_MAXSIZE = _seed_hash_cache.maxsize
def make_rng(
seed: int, key: int, G: TNFRGraph | GraphLike | None = None
) -> random.Random:
"""Create a reproducible RNG instance from seed and key.
This factory constructs a deterministic :class:`random.Random` generator
by hashing the seed and key together. The hash result is cached for
performance when the same (seed, key) pair is requested repeatedly.
Parameters
----------
seed : int
Base random seed for the generator. Must be an integer.
key : int
Key used to derive a unique hash with the seed. Multiple keys
allow independent RNG streams from the same base seed.
G : TNFRGraph | GraphLike | None, optional
Graph containing JITTER_CACHE_SIZE parameter. When provided, the
internal cache size is synchronized with the graph configuration.
Returns
-------
random.Random
Deterministic random number generator seeded with hash(seed, key).
Notes
-----
The same (seed, key) pair always produces the same generator state,
ensuring reproducibility across TNFR simulations. Cache synchronization
with ``G`` allows adaptive caching based on simulation requirements.
"""
_sync_cache_size(G)
seed_int = int(seed)
key_int = int(key)
return random.Random(seed_hash(seed_int, key_int))
def clear_rng_cache() -> None:
"""Clear cached seed hashes."""
if _seed_hash_cache.maxsize <= 0 or not _seed_hash_cache.enabled:
return
seed_hash.cache_clear() # type: ignore[attr-defined]
def get_cache_maxsize(G: TNFRGraph | GraphLike) -> int:
"""Return RNG cache maximum size for ``G``."""
return int(get_param(G, "JITTER_CACHE_SIZE"))
def cache_enabled(G: TNFRGraph | GraphLike | None = None) -> bool:
"""Return ``True`` if RNG caching is enabled.
When ``G`` is provided, the cache size is synchronised with
``JITTER_CACHE_SIZE`` stored in ``G``.
"""
# Only synchronise the cache size with ``G`` when caching is enabled. This
# preserves explicit calls to :func:`set_cache_maxsize(0)` which are used in
# tests to temporarily disable caching regardless of graph defaults.
if _seed_hash_cache.maxsize > 0:
_sync_cache_size(G)
return _seed_hash_cache.maxsize > 0
def base_seed(G: TNFRGraph | GraphLike) -> int:
"""Return base RNG seed stored in ``G.graph``."""
graph = get_graph(G)
return int(graph.get("RANDOM_SEED", 0))
def _rng_for_step(seed: int, step: int) -> random.Random:
"""Return deterministic RNG for a simulation ``step``."""
return make_rng(seed, step)
def set_cache_maxsize(size: int) -> None:
"""Update RNG cache maximum size.
``size`` must be a non-negative integer; ``0`` disables caching.
Changing the cache size resets any cached seed hashes.
If caching is disabled, ``clear_rng_cache`` has no effect.
"""
global _CACHE_MAXSIZE, _CACHE_LOCKED
new_size = int(size)
if new_size < 0:
raise ValueError("size must be non-negative")
with _RNG_LOCK:
_seed_hash_cache.configure(new_size)
_CACHE_MAXSIZE = _seed_hash_cache.maxsize
_CACHE_LOCKED = new_size != _DEFAULT_CACHE_MAXSIZE
__all__ = (
"seed_hash",
"make_rng",
"get_cache_maxsize",
"set_cache_maxsize",
"base_seed",
"cache_enabled",
"clear_rng_cache",
"ScopedCounterCache",
)