From 4aa41adb2023db8e8d503f2aa2fbf2a4fcec2256 Mon Sep 17 00:00:00 2001 From: mwisnowski <93788087+mwisnowski@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:03:17 -0700 Subject: [PATCH] feat: add RandomService, seed diagnostics endpoint, and random mode docs (#59) --- .env.example | 2 +- CHANGELOG.md | 5 +- RELEASE_NOTES_TEMPLATE.md | 4 +- code/exceptions.py | 14 +- code/tests/test_random_service.py | 149 +++++++++++++++++++ code/web/app.py | 21 +++ code/web/services/random_service.py | 130 +++++++++++++++++ docs/random_mode/developer_guide.md | 185 ++++++++++++++++++++++++ docs/random_mode/diagnostics.md | 70 +++++++++ docs/random_mode/seed_infrastructure.md | 121 ++++++++++++++++ 10 files changed, 697 insertions(+), 4 deletions(-) create mode 100644 code/tests/test_random_service.py create mode 100644 code/web/services/random_service.py create mode 100644 docs/random_mode/developer_guide.md create mode 100644 docs/random_mode/diagnostics.md create mode 100644 docs/random_mode/seed_infrastructure.md diff --git a/.env.example b/.env.example index 459c63b..756be71 100644 --- a/.env.example +++ b/.env.example @@ -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_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) -# 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_PRESETS=0 # dockerhub: ENABLE_PRESETS="0" WEB_VIRTUALIZE=1 # dockerhub: WEB_VIRTUALIZE="1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c8cfc8..d0b8203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning ## [Unreleased] ### 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 _No unreleased changes yet_ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index 75ff842..c4df7f0 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -2,7 +2,9 @@ ## [Unreleased] ### 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 _No unreleased changes yet_ diff --git a/code/exceptions.py b/code/exceptions.py index 3b7fad2..af9d16a 100644 --- a/code/exceptions.py +++ b/code/exceptions.py @@ -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}) \ No newline at end of file + 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) \ No newline at end of file diff --git a/code/tests/test_random_service.py b/code/tests/test_random_service.py new file mode 100644 index 0000000..fe3f192 --- /dev/null +++ b/code/tests/test_random_service.py @@ -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 diff --git a/code/web/app.py b/code/web/app.py index 20013ed..6eca1a7 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -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: diff --git a/code/web/services/random_service.py b/code/web/services/random_service.py new file mode 100644 index 0000000..3126829 --- /dev/null +++ b/code/web/services/random_service.py @@ -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() diff --git a/docs/random_mode/developer_guide.md b/docs/random_mode/developer_guide.md new file mode 100644 index 0000000..d4b3894 --- /dev/null +++ b/docs/random_mode/developer_guide.md @@ -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 | diff --git a/docs/random_mode/diagnostics.md b/docs/random_mode/diagnostics.md new file mode 100644 index 0000000..626362c --- /dev/null +++ b/docs/random_mode/diagnostics.md @@ -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()` diff --git a/docs/random_mode/seed_infrastructure.md b/docs/random_mode/seed_infrastructure.md new file mode 100644 index 0000000..75a15b7 --- /dev/null +++ b/docs/random_mode/seed_infrastructure.md @@ -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 +```