feat: add RandomService, seed diagnostics endpoint, and random mode docs (#59)
Some checks are pending
CI / build (push) Waiting to run

This commit is contained in:
mwisnowski 2026-03-20 21:03:17 -07:00 committed by GitHub
parent 7e5a29dd74
commit 4aa41adb20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 697 additions and 4 deletions

View file

@ -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"

View file

@ -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_

View file

@ -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_

View file

@ -1420,4 +1420,16 @@ 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)

View 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

View file

@ -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:

View 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()

View 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 |

View 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()`

View 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
```