mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-25 06:26:31 +01:00
feat: add RandomService, seed diagnostics endpoint, and random mode docs
This commit is contained in:
parent
7e5a29dd74
commit
c4eb3bcd5a
10 changed files with 697 additions and 4 deletions
|
|
@ -1420,4 +1420,16 @@ class FeatureDisabledError(DeckBuilderError):
|
|||
|
||||
def __init__(self, feature: str, details: dict | None = None):
|
||||
message = f"Feature '{feature}' is currently disabled"
|
||||
super().__init__(message, code="FEATURE_DISABLED", details=details or {"feature": feature})
|
||||
super().__init__(message, code="FEATURE_DISABLED", details=details or {"feature": feature})
|
||||
|
||||
|
||||
# Random Mode Exceptions
|
||||
class InvalidSeedError(DeckBuilderError):
|
||||
"""Raised when a seed value fails validation.
|
||||
|
||||
Used by RandomService when the provided seed is the wrong type,
|
||||
out of range, or otherwise cannot be processed.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, details: dict | None = None):
|
||||
super().__init__(message, code="INVALID_SEED", details=details)
|
||||
149
code/tests/test_random_service.py
Normal file
149
code/tests/test_random_service.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Tests for RandomService.
|
||||
|
||||
Covers seed validation, seed derivation, and RNG creation via the service.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from code.exceptions import InvalidSeedError
|
||||
from code.web.services.random_service import RandomService
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@pytest.fixture
|
||||
def service() -> RandomService:
|
||||
return RandomService()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# validate_seed #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestValidateSeed:
|
||||
def test_none_is_valid(self, service):
|
||||
service.validate_seed(None) # should not raise
|
||||
|
||||
def test_positive_int_is_valid(self, service):
|
||||
service.validate_seed(0)
|
||||
service.validate_seed(1)
|
||||
service.validate_seed(12345)
|
||||
service.validate_seed((1 << 63) - 1)
|
||||
|
||||
def test_nonempty_string_is_valid(self, service):
|
||||
service.validate_seed("dragons")
|
||||
service.validate_seed("1")
|
||||
service.validate_seed(" ") # whitespace-only is allowed by service
|
||||
|
||||
def test_negative_int_raises(self, service):
|
||||
with pytest.raises(InvalidSeedError) as exc_info:
|
||||
service.validate_seed(-1)
|
||||
assert exc_info.value.code == "INVALID_SEED"
|
||||
|
||||
def test_empty_string_raises(self, service):
|
||||
with pytest.raises(InvalidSeedError):
|
||||
service.validate_seed("")
|
||||
|
||||
def test_bool_raises(self, service):
|
||||
with pytest.raises(InvalidSeedError):
|
||||
service.validate_seed(True)
|
||||
with pytest.raises(InvalidSeedError):
|
||||
service.validate_seed(False)
|
||||
|
||||
def test_list_raises(self, service):
|
||||
with pytest.raises(InvalidSeedError):
|
||||
service.validate_seed([1, 2, 3]) # type: ignore
|
||||
|
||||
def test_dict_raises(self, service):
|
||||
with pytest.raises(InvalidSeedError):
|
||||
service.validate_seed({"seed": 1}) # type: ignore
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# derive_seed #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestDeriveSeed:
|
||||
def test_int_seed_stable(self, service):
|
||||
assert service.derive_seed(42) == 42
|
||||
assert service.derive_seed(0) == 0
|
||||
|
||||
def test_string_seed_stable(self, service):
|
||||
s1 = service.derive_seed("test-seed")
|
||||
s2 = service.derive_seed("test-seed")
|
||||
assert s1 == s2
|
||||
|
||||
def test_string_seed_known_value(self, service):
|
||||
# Same expected value as test_random_util.py
|
||||
assert service.derive_seed("test-seed") == 6214070892065607348
|
||||
|
||||
def test_different_strings_differ(self, service):
|
||||
a = service.derive_seed("alpha")
|
||||
b = service.derive_seed("beta")
|
||||
assert a != b
|
||||
|
||||
def test_negative_int_raises(self, service):
|
||||
with pytest.raises(InvalidSeedError):
|
||||
service.derive_seed(-5)
|
||||
|
||||
def test_result_within_63_bits(self, service):
|
||||
for seed in [0, 1, 99999, "hello", "dragons"]:
|
||||
result = service.derive_seed(seed)
|
||||
assert 0 <= result < (1 << 63)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# create_rng #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestCreateRng:
|
||||
def test_seeded_rng_is_deterministic(self, service):
|
||||
rng1 = service.create_rng(seed=12345)
|
||||
rng2 = service.create_rng(seed=12345)
|
||||
seq1 = [rng1.random() for _ in range(5)]
|
||||
seq2 = [rng2.random() for _ in range(5)]
|
||||
assert seq1 == seq2
|
||||
|
||||
def test_string_seeded_rng_is_deterministic(self, service):
|
||||
rng1 = service.create_rng(seed="dragons")
|
||||
rng2 = service.create_rng(seed="dragons")
|
||||
seq1 = [rng1.random() for _ in range(5)]
|
||||
seq2 = [rng2.random() for _ in range(5)]
|
||||
assert seq1 == seq2
|
||||
|
||||
def test_different_seeds_produce_different_streams(self, service):
|
||||
rng1 = service.create_rng(seed=1)
|
||||
rng2 = service.create_rng(seed=2)
|
||||
seq1 = [rng1.random() for _ in range(10)]
|
||||
seq2 = [rng2.random() for _ in range(10)]
|
||||
assert seq1 != seq2
|
||||
|
||||
def test_unseeded_returns_independent_instance(self, service):
|
||||
rng1 = service.create_rng()
|
||||
rng2 = service.create_rng()
|
||||
assert rng1 is not rng2
|
||||
|
||||
def test_seeded_rng_is_independent_object(self, service):
|
||||
rng1 = service.create_rng(seed=42)
|
||||
rng2 = service.create_rng(seed=42)
|
||||
assert rng1 is not rng2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# generate_seed #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class TestGenerateSeed:
|
||||
def test_returns_int_in_range(self, service):
|
||||
for _ in range(10):
|
||||
s = service.generate_seed()
|
||||
assert isinstance(s, int)
|
||||
assert 0 <= s < (1 << 63)
|
||||
|
||||
def test_seeds_are_not_all_identical(self, service):
|
||||
seeds = {service.generate_seed() for _ in range(5)}
|
||||
# With 63-bit entropy collisions are astronomically unlikely
|
||||
assert len(seeds) > 1
|
||||
|
|
@ -2177,6 +2177,27 @@ async def api_random_seed_favorite(request: Request):
|
|||
rid = getattr(request.state, "request_id", None)
|
||||
return {"ok": True, "favorites": favs, "request_id": rid}
|
||||
|
||||
@app.get("/api/random/diagnostics")
|
||||
async def api_random_diagnostics(request: Request):
|
||||
"""Seed verification diagnostics (requires WEB_RANDOM_DIAGNOSTICS=1)."""
|
||||
if not os.environ.get("WEB_RANDOM_DIAGNOSTICS"):
|
||||
raise HTTPException(status_code=404, detail="Diagnostics disabled")
|
||||
from code.web.services.random_service import RandomService
|
||||
service = RandomService()
|
||||
test_vectors = {
|
||||
"test-seed": service.derive_seed("test-seed"),
|
||||
"12345": service.derive_seed(12345),
|
||||
"zero": service.derive_seed(0),
|
||||
"empty-string-rejected": "N/A (empty string raises InvalidSeedError)",
|
||||
}
|
||||
rid = getattr(request.state, "request_id", None)
|
||||
return {
|
||||
"test_vectors": test_vectors,
|
||||
"seed_algorithm": "sha256-63bit",
|
||||
"version": "1.0",
|
||||
"request_id": rid,
|
||||
}
|
||||
|
||||
@app.get("/status/random_metrics_ndjson")
|
||||
async def status_random_metrics_ndjson():
|
||||
if not RANDOM_TELEMETRY:
|
||||
|
|
|
|||
130
code/web/services/random_service.py
Normal file
130
code/web/services/random_service.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Service wrapper for deterministic seeded RNG operations.
|
||||
|
||||
Follows the R9 BaseService pattern. Route handlers should prefer this
|
||||
over calling random_util functions directly so that validation and error
|
||||
handling are centralised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Optional, Union
|
||||
|
||||
from code.exceptions import InvalidSeedError
|
||||
from code.random_util import (
|
||||
derive_seed_from_string,
|
||||
generate_seed as _generate_seed,
|
||||
get_random,
|
||||
)
|
||||
from code.web.services.base import BaseService
|
||||
|
||||
SeedLike = Union[int, str]
|
||||
|
||||
|
||||
class RandomService(BaseService):
|
||||
"""Service for deterministic random build operations.
|
||||
|
||||
All methods return independent ``random.Random`` instances so that
|
||||
concurrent requests cannot pollute each other's RNG stream.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Validation #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def validate_seed(self, seed: Optional[SeedLike]) -> None:
|
||||
"""Validate that *seed* is an acceptable type and value.
|
||||
|
||||
Args:
|
||||
seed: The seed to validate. ``None`` is always valid (triggers
|
||||
auto-generation at build time).
|
||||
|
||||
Raises:
|
||||
InvalidSeedError: If the seed is the wrong type, negative, or
|
||||
an empty string.
|
||||
"""
|
||||
if seed is None:
|
||||
return
|
||||
|
||||
if isinstance(seed, bool):
|
||||
# bool is a subclass of int in Python — reject explicitly
|
||||
raise InvalidSeedError(
|
||||
"Seed must be an integer or string, not bool",
|
||||
details={"seed": str(seed), "type": type(seed).__name__},
|
||||
)
|
||||
|
||||
if isinstance(seed, int):
|
||||
if seed < 0:
|
||||
raise InvalidSeedError(
|
||||
f"Integer seed must be non-negative, got {seed}",
|
||||
details={"seed": seed},
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(seed, str):
|
||||
if not seed:
|
||||
raise InvalidSeedError(
|
||||
"String seed cannot be empty",
|
||||
details={"seed": seed},
|
||||
)
|
||||
return
|
||||
|
||||
raise InvalidSeedError(
|
||||
f"Seed must be an int or str, got {type(seed).__name__}",
|
||||
details={"seed": str(seed), "type": type(seed).__name__},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Seed derivation #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def derive_seed(self, seed: SeedLike) -> int:
|
||||
"""Derive a stable 63-bit positive integer from *seed*.
|
||||
|
||||
Delegates to :func:`random_util.derive_seed_from_string` after
|
||||
validating the input.
|
||||
|
||||
Args:
|
||||
seed: Integer or string seed value.
|
||||
|
||||
Returns:
|
||||
A stable non-negative 63-bit integer.
|
||||
|
||||
Raises:
|
||||
InvalidSeedError: If *seed* fails validation.
|
||||
"""
|
||||
self.validate_seed(seed)
|
||||
try:
|
||||
return derive_seed_from_string(seed)
|
||||
except Exception as exc:
|
||||
raise InvalidSeedError(
|
||||
f"Failed to derive seed from {type(seed).__name__}",
|
||||
details={"seed": str(seed), "error": str(exc)},
|
||||
) from exc
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# RNG creation #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def create_rng(self, seed: Optional[SeedLike] = None) -> random.Random:
|
||||
"""Return a ``random.Random`` instance, optionally seeded.
|
||||
|
||||
Args:
|
||||
seed: Integer or string seed. Pass ``None`` for an unseeded
|
||||
(non-deterministic) instance.
|
||||
|
||||
Returns:
|
||||
An independent ``random.Random`` instance.
|
||||
"""
|
||||
return get_random(seed)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Seed generation #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def generate_seed(self) -> int:
|
||||
"""Generate a high-entropy 63-bit seed.
|
||||
|
||||
Returns:
|
||||
A fresh positive integer via ``secrets.randbits(63)``.
|
||||
"""
|
||||
return _generate_seed()
|
||||
Loading…
Add table
Add a link
Reference in a new issue