mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 14:06:31 +01:00
feat: add RandomService, seed diagnostics endpoint, and random mode docs (#59)
Some checks are pending
CI / build (push) Waiting to run
Some checks are pending
CI / build (push) Waiting to run
This commit is contained in:
parent
7e5a29dd74
commit
4aa41adb20
10 changed files with 697 additions and 4 deletions
|
|
@ -51,7 +51,7 @@ SHOW_THEME_QUALITY_BADGES=1 # dockerhub: SHOW_THEME_QUALITY_BADGES="1" (
|
||||||
SHOW_THEME_POOL_BADGES=1 # dockerhub: SHOW_THEME_POOL_BADGES="1" (show pool size badges in theme catalog)
|
SHOW_THEME_POOL_BADGES=1 # dockerhub: SHOW_THEME_POOL_BADGES="1" (show pool size badges in theme catalog)
|
||||||
SHOW_THEME_POPULARITY_BADGES=1 # dockerhub: SHOW_THEME_POPULARITY_BADGES="1" (show popularity badges in theme catalog)
|
SHOW_THEME_POPULARITY_BADGES=1 # dockerhub: SHOW_THEME_POPULARITY_BADGES="1" (show popularity badges in theme catalog)
|
||||||
SHOW_THEME_FILTERS=1 # dockerhub: SHOW_THEME_FILTERS="1" (show filter dropdowns/chips in theme catalog)
|
SHOW_THEME_FILTERS=1 # dockerhub: SHOW_THEME_FILTERS="1" (show filter dropdowns/chips in theme catalog)
|
||||||
# THEME_POOL_SECTIONS=1 # dockerhub: THEME_POOL_SECTIONS="0" (1=group themes by pool size sections: Vast/Large/Moderate/Small/Tiny; 0=flat sorted list)
|
THEME_POOL_SECTIONS=1 # dockerhub: THEME_POOL_SECTIONS="0" (1=group themes by pool size sections: Vast/Large/Moderate/Small/Tiny; 0=flat sorted list)
|
||||||
ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0"
|
ENABLE_PWA=0 # dockerhub: ENABLE_PWA="0"
|
||||||
ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
|
ENABLE_PRESETS=0 # dockerhub: ENABLE_PRESETS="0"
|
||||||
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
|
WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
_No unreleased changes yet_
|
- **RandomService**: New `code/web/services/random_service.py` service class wrapping seeded RNG operations with input validation and the R9 `BaseService` pattern
|
||||||
|
- **InvalidSeedError**: New `InvalidSeedError` exception in `code/exceptions.py` for seed validation failures
|
||||||
|
- **Random diagnostics endpoint**: `GET /api/random/diagnostics` behind `WEB_RANDOM_DIAGNOSTICS=1` flag, returning seed derivation test vectors for cross-platform consistency checks
|
||||||
|
- **Random Mode documentation**: New `docs/random_mode/` directory with `seed_infrastructure.md`, `developer_guide.md`, and `diagnostics.md`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
_No unreleased changes yet_
|
_No unreleased changes yet_
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
_No unreleased changes yet_
|
- **RandomService**: Service wrapper for seeded RNG with validation (`code/web/services/random_service.py`)
|
||||||
|
- **Random diagnostics**: `GET /api/random/diagnostics` endpoint (requires `WEB_RANDOM_DIAGNOSTICS=1`)
|
||||||
|
- **Random Mode docs**: `docs/random_mode/` covering seed infrastructure, developer guide, and diagnostics
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
_No unreleased changes yet_
|
_No unreleased changes yet_
|
||||||
|
|
|
||||||
|
|
@ -1421,3 +1421,15 @@ class FeatureDisabledError(DeckBuilderError):
|
||||||
def __init__(self, feature: str, details: dict | None = None):
|
def __init__(self, feature: str, details: dict | None = None):
|
||||||
message = f"Feature '{feature}' is currently disabled"
|
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)
|
rid = getattr(request.state, "request_id", None)
|
||||||
return {"ok": True, "favorites": favs, "request_id": rid}
|
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")
|
@app.get("/status/random_metrics_ndjson")
|
||||||
async def status_random_metrics_ndjson():
|
async def status_random_metrics_ndjson():
|
||||||
if not RANDOM_TELEMETRY:
|
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()
|
||||||
185
docs/random_mode/developer_guide.md
Normal file
185
docs/random_mode/developer_guide.md
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
# Random Mode Developer Guide
|
||||||
|
|
||||||
|
**Updated**: 2026-03-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide covers how to use the seeded RNG infrastructure in new code, how to wire seeds through routes and services, and how to test deterministic builds.
|
||||||
|
|
||||||
|
See [seed_infrastructure.md](seed_infrastructure.md) for the API reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Reproduce a build from a seed
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.random_util import set_seed
|
||||||
|
|
||||||
|
rng = set_seed(12345)
|
||||||
|
value = rng.choice(["a", "b", "c"]) # Always the same for seed 12345
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate a fresh seed
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.random_util import generate_seed
|
||||||
|
|
||||||
|
seed = generate_seed() # High-entropy 63-bit int
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use the service (preferred in route handlers)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.web.services.random_service import RandomService
|
||||||
|
|
||||||
|
service = RandomService()
|
||||||
|
rng = service.create_rng(seed=12345)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Examples
|
||||||
|
|
||||||
|
### 1. Seeded random build (headless / CLI)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.deck_builder.random_entrypoint import build_random_full_deck
|
||||||
|
|
||||||
|
result = build_random_full_deck(
|
||||||
|
seed=12345,
|
||||||
|
theme="dragons",
|
||||||
|
color_identity=["R", "G"],
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
print(result.seed) # 12345
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Web route handler with seed from request
|
||||||
|
|
||||||
|
```python
|
||||||
|
# code/web/app.py or a route file
|
||||||
|
from code.web.services.random_service import RandomService
|
||||||
|
|
||||||
|
async def my_random_route(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
raw_seed = body.get("seed") # int, str, or None
|
||||||
|
|
||||||
|
service = RandomService()
|
||||||
|
service.validate_seed(raw_seed) # raises InvalidSeedError on bad input
|
||||||
|
seed = service.derive_seed(raw_seed) if raw_seed is not None else service.generate_seed()
|
||||||
|
|
||||||
|
rng = service.create_rng(seed)
|
||||||
|
# ... pass rng or seed into build function
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Builder-level seeding
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.deck_builder.builder import DeckBuilder
|
||||||
|
|
||||||
|
builder = DeckBuilder(...)
|
||||||
|
builder.set_seed(99999) # builder.rng is now seeded
|
||||||
|
|
||||||
|
cards = builder.rng.sample(card_pool, 10) # Reproducible sample
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Reroll (increment seed)
|
||||||
|
|
||||||
|
```python
|
||||||
|
old_seed = session.get("random_seed")
|
||||||
|
new_seed = (old_seed + 1) & ((1 << 63) - 1) # Stay in 63-bit range
|
||||||
|
session["random_seed"] = new_seed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seed Validation Rules
|
||||||
|
|
||||||
|
| Input | Valid? | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `42` | Yes | Non-negative int |
|
||||||
|
| `-1` | No | Negative int → `InvalidSeedError` |
|
||||||
|
| `"dragons"` | Yes | String → SHA-256 derivation |
|
||||||
|
| `""` | No | Empty string → `InvalidSeedError` |
|
||||||
|
| `None` | Yes | Triggers auto-generation |
|
||||||
|
| `[]` or `{}` | No | Wrong type → `InvalidSeedError` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.exceptions import InvalidSeedError
|
||||||
|
from code.web.services.random_service import RandomService
|
||||||
|
|
||||||
|
service = RandomService()
|
||||||
|
try:
|
||||||
|
service.validate_seed(user_input)
|
||||||
|
except InvalidSeedError as e:
|
||||||
|
return {"error": str(e), "code": e.code}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Deterministic Code
|
||||||
|
|
||||||
|
### Assert two builds produce the same result
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_build_is_deterministic():
|
||||||
|
result1 = build_random_full_deck(seed=42, ...)
|
||||||
|
result2 = build_random_full_deck(seed=42, ...)
|
||||||
|
assert result1.commander == result2.commander
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assert different seeds produce different results (probabilistic)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_different_seeds_differ():
|
||||||
|
result1 = build_random_full_deck(seed=1, ...)
|
||||||
|
result2 = build_random_full_deck(seed=2, ...)
|
||||||
|
# Not guaranteed, but highly likely for large pools
|
||||||
|
assert result1.commander != result2.commander or result1.theme != result2.theme
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test seed derivation stability
|
||||||
|
|
||||||
|
```python
|
||||||
|
from code.random_util import derive_seed_from_string
|
||||||
|
|
||||||
|
def test_string_seed_stable():
|
||||||
|
s1 = derive_seed_from_string("test")
|
||||||
|
s2 = derive_seed_from_string("test")
|
||||||
|
assert s1 == s2
|
||||||
|
|
||||||
|
def test_int_and_string_differ():
|
||||||
|
assert derive_seed_from_string(0) != derive_seed_from_string("0")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
When `WEB_RANDOM_DIAGNOSTICS=1` is set, the endpoint `/api/random/diagnostics` returns seed derivation test vectors and algorithm metadata. Useful for verifying cross-platform consistency.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEB_RANDOM_DIAGNOSTICS=1 curl http://localhost:5000/api/random/diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `code/random_util.py` | Core RNG utilities |
|
||||||
|
| `code/web/services/random_service.py` | Service wrapper with validation |
|
||||||
|
| `code/exceptions.py` | `InvalidSeedError` |
|
||||||
|
| `code/deck_builder/random_entrypoint.py` | `build_random_deck`, `build_random_full_deck` |
|
||||||
|
| `code/deck_builder/builder.py` | `DeckBuilder.seed` / `DeckBuilder.rng` |
|
||||||
|
| `docs/random_mode/seed_infrastructure.md` | API reference |
|
||||||
|
| `docs/random_mode/diagnostics.md` | Diagnostics endpoint reference |
|
||||||
70
docs/random_mode/diagnostics.md
Normal file
70
docs/random_mode/diagnostics.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Random Mode Diagnostics
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/random/diagnostics`
|
||||||
|
**Feature flag**: `WEB_RANDOM_DIAGNOSTICS=1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The diagnostics endpoint exposes seed derivation test vectors and algorithm metadata. It is intended for internal tooling and cross-platform consistency checks — verifying that seed derivation produces identical results across environments, Python versions, or deployments.
|
||||||
|
|
||||||
|
The endpoint returns **404** unless `WEB_RANDOM_DIAGNOSTICS=1` is set in the environment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEB_RANDOM_DIAGNOSTICS=1 curl http://localhost:5000/api/random/diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"test_vectors": {
|
||||||
|
"test-seed": 6214070892065607348,
|
||||||
|
"12345": 12345,
|
||||||
|
"zero": 0,
|
||||||
|
"empty-string-rejected": "N/A (empty string raises InvalidSeedError)"
|
||||||
|
},
|
||||||
|
"seed_algorithm": "sha256-63bit",
|
||||||
|
"version": "1.0",
|
||||||
|
"request_id": "abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Field Reference
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `test_vectors` | object | Known-input → expected-output pairs for manual verification |
|
||||||
|
| `seed_algorithm` | string | Algorithm identifier (`sha256-63bit`) |
|
||||||
|
| `version` | string | Diagnostics schema version |
|
||||||
|
| `request_id` | string \| null | Request tracing ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seed Algorithm Details
|
||||||
|
|
||||||
|
String seeds are processed as:
|
||||||
|
1. UTF-8 encode the input string
|
||||||
|
2. SHA-256 hash the bytes
|
||||||
|
3. Take the first 8 bytes as a big-endian unsigned integer
|
||||||
|
4. Mask to 63 bits: `n & ((1 << 63) - 1)`
|
||||||
|
|
||||||
|
Integer seeds are normalised as: `abs(n) & ((1 << 63) - 1)`.
|
||||||
|
|
||||||
|
This ensures all seeds are non-negative, platform-independent, and fit within Python's `random.Random.seed()` expectations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [seed_infrastructure.md](seed_infrastructure.md) — API reference
|
||||||
|
- [developer_guide.md](developer_guide.md) — Integration guide
|
||||||
|
- `code/web/services/random_service.py` — `RandomService.derive_seed()`
|
||||||
|
- `code/random_util.py` — `derive_seed_from_string()`
|
||||||
121
docs/random_mode/seed_infrastructure.md
Normal file
121
docs/random_mode/seed_infrastructure.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Seed Infrastructure
|
||||||
|
|
||||||
|
**Module**: `code/random_util.py`
|
||||||
|
**Updated**: 2026-03-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Random Mode builds use a deterministic, seeded RNG so that any build can be exactly reproduced from its seed value. Every random operation flows through isolated `random.Random` instances — the module-level PRNG is never mutated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
### `code/random_util.py`
|
||||||
|
|
||||||
|
| Function | Signature | Description |
|
||||||
|
|----------|-----------|-------------|
|
||||||
|
| `derive_seed_from_string` | `(seed: int \| str) -> int` | Stable 63-bit seed from int or string (SHA-256 for strings) |
|
||||||
|
| `set_seed` | `(seed: int \| str) -> random.Random` | Create a seeded `Random` instance |
|
||||||
|
| `get_random` | `(seed: int \| str \| None) -> random.Random` | Convenience wrapper; unseeded when `None` |
|
||||||
|
| `generate_seed` | `() -> int` | High-entropy 63-bit seed via `secrets.randbits` |
|
||||||
|
|
||||||
|
### `code/web/services/random_service.py`
|
||||||
|
|
||||||
|
Thin service wrapper following the R9 `BaseService` pattern. Adds input validation and a standardised interface for route handlers.
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `derive_seed(seed)` | Validated seed derivation (raises `InvalidSeedError` on bad input) |
|
||||||
|
| `create_rng(seed)` | Return seeded or unseeded `random.Random` |
|
||||||
|
| `generate_seed()` | Delegates to `random_util.generate_seed()` |
|
||||||
|
| `validate_seed(seed)` | Validates type and range; raises `InvalidSeedError` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seed Types
|
||||||
|
|
||||||
|
### Integer seeds
|
||||||
|
- Must be non-negative
|
||||||
|
- Normalised to 63-bit via `abs(n) & ((1 << 63) - 1)`
|
||||||
|
- Zero is a valid, deterministic seed
|
||||||
|
|
||||||
|
### String seeds
|
||||||
|
- Encoded as UTF-8 bytes
|
||||||
|
- Hashed with SHA-256; first 8 bytes taken as big-endian unsigned int
|
||||||
|
- Masked to 63 bits for consistency with int path
|
||||||
|
- Empty string is valid (produces a fixed deterministic seed)
|
||||||
|
|
||||||
|
### None (auto-seed)
|
||||||
|
- `get_random(None)` returns an unseeded `random.Random()`
|
||||||
|
- `generate_seed()` returns a fresh `secrets.randbits(63)` value each call
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### DeckBuilder
|
||||||
|
```python
|
||||||
|
# code/deck_builder/builder.py
|
||||||
|
builder = DeckBuilder(...)
|
||||||
|
builder.set_seed(12345) # Sets builder.seed and recreates builder.rng
|
||||||
|
rng = builder.rng # Seeded random.Random instance
|
||||||
|
```
|
||||||
|
|
||||||
|
### Random entrypoint
|
||||||
|
```python
|
||||||
|
# code/deck_builder/random_entrypoint.py
|
||||||
|
result = build_random_full_deck(
|
||||||
|
seed=12345,
|
||||||
|
theme="dragons",
|
||||||
|
# ...
|
||||||
|
)
|
||||||
|
# result.seed == 12345 (or auto-generated if None was passed)
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
```bash
|
||||||
|
python code/headless_runner.py --random-seed 12345
|
||||||
|
# or via environment variable
|
||||||
|
RANDOM_SEED=12345 python code/headless_runner.py
|
||||||
|
# or in deck.json config
|
||||||
|
{ "random_seed": 12345 }
|
||||||
|
```
|
||||||
|
Seed resolution order: `--random-seed` CLI arg > `RANDOM_SEED` env var > `random_seed` in JSON config.
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
- Seed input field on the Random Build page
|
||||||
|
- Seed persisted in session across rerolls
|
||||||
|
- Reroll increments the seed by 1 for deterministic variation (`seed + 1`)
|
||||||
|
- Favorite seeds stored in session for quick reuse
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Behaviour |
|
||||||
|
|----------|-----------|
|
||||||
|
| Negative int seed | Normalised via `abs()` before masking |
|
||||||
|
| `MAX_INT` seed | Masked to 63 bits — stays valid |
|
||||||
|
| Empty string `""` | Valid; SHA-256 hash of empty bytes used |
|
||||||
|
| String with special chars / Unicode | UTF-8 encoded; `errors="strict"`, fallback to `errors="ignore"` |
|
||||||
|
| `None` seed | Unseeded `random.Random()` — non-deterministic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|------|----------|
|
||||||
|
| `code/tests/test_random_util.py` | Core function contracts |
|
||||||
|
| `code/tests/test_random_determinism_comprehensive.py` | End-to-end determinism validation |
|
||||||
|
| `code/tests/test_random_features_comprehensive.py` | Random mode feature integration |
|
||||||
|
| `code/tests/test_random_api_comprehensive.py` | API-level behaviour |
|
||||||
|
| `code/tests/test_random_service.py` | `RandomService` unit tests |
|
||||||
|
|
||||||
|
Run the fast subset:
|
||||||
|
```powershell
|
||||||
|
.venv/Scripts/python.exe -m pytest -q code/tests/test_random_util.py code/tests/test_random_service.py
|
||||||
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue