feat: add RandomService, seed diagnostics endpoint, and random mode docs

This commit is contained in:
matt 2026-03-20 18:35:52 -07:00
parent 7e5a29dd74
commit c4eb3bcd5a
10 changed files with 697 additions and 4 deletions

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