docs(decisions): amend D-224 with #952 review findings
- Pin the Body domain id scheme: body_id(String) -> FNV-1a(64) -> derive(Body, …), via SeedChain::for_body. Closes the gap where "keyed by body StableId" named a numeric id that doesn't exist (bodies are strings). - Add for_body to the contract; document the stability guards (SeedDomain #[repr(u64)] + pin test, AttractorType #[repr(u8)]). - Note EntityRng keeps its domainless combine (re-expressing as derive(Npc, …) is a stream-changing migration, deferred). - Correct the implementation note: golden is JSON (matching golden_suite.rs), not msgpack; run at 256x128 for a non-empty river network. Fix the stale skeleton_gen.rs:248 line reference. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1446,9 +1446,9 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
|
||||
### D-224: SeedChain — deterministic seed-derivation contract
|
||||
- **Date:** 2026-05-23
|
||||
- **Decision:** All deterministic generation — the Phase-4 world cascade now, and NPC / storyteller / economic generation later — descends from a single master world seed through one shared type, **`SeedChain`**, living at **`server/src/seed.rs`** (top-level, because seeds are broader than the atlas). It is the **only** sanctioned way to derive a child seed; ad-hoc pre-mixing (`seed.wrapping_add(C)`, currently in `skeleton_gen.rs:248`) is removed, and the doc-comment references to a "SeedChain" in `skeleton_gen.rs:42` / `generator.rs:655` become real.
|
||||
- **Decision:** All deterministic generation — the Phase-4 world cascade now, and NPC / storyteller / economic generation later — descends from a single master world seed through one shared type, **`SeedChain`**, living at **`server/src/seed.rs`** (top-level, because seeds are broader than the atlas). It is the **only** sanctioned way to derive a child seed; the ad-hoc pre-mixing (`seed.wrapping_add(C)`) that was in `skeleton_gen.rs` / `district_mix.rs` is removed, and the doc-comment references to a "SeedChain" in those files become real.
|
||||
|
||||
**Mixing primitive.** `splitmix64` — already used by `EntityRng::from_seed_and_id` (`simulation/rng.rs`), chosen there specifically to avoid the `wrapping_add` collision class where `(seed=0,id=N) == (seed=1,id=N-1)` — is promoted to a shared `pub(crate)` function in `server/src/seed.rs` and reused. One canonical mixer for the whole codebase.
|
||||
**Mixing primitive.** `splitmix64` — already used by `EntityRng::from_seed_and_id` (`simulation/rng.rs`), chosen there specifically to avoid the `wrapping_add` collision class where `(seed=0,id=N) == (seed=1,id=N-1)` — is promoted to a shared `pub(crate)` function in `server/src/seed.rs` and reused. One canonical *mixer* for the whole codebase. (`EntityRng` keeps its own domainless combine — `splitmix64(world_seed) ^ splitmix64(stable_id)` — for now; re-expressing it as `derive(Npc, stable_id)` would be a deliberate, **stream-changing** migration, not a free cleanup, so it is left as a follow-up.)
|
||||
|
||||
**Contract:**
|
||||
```rust
|
||||
@@ -1456,6 +1456,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
pub enum SeedDomain { Body, Layer1Topography, Layer3Settlement, Layer4Quarter, Block, Npc /* … */ } // u64 tags
|
||||
impl SeedChain {
|
||||
pub fn root(world_seed: u64) -> Self; // top of the chain
|
||||
pub fn for_body(world_seed: u64, body_id: &str) -> Self; // root + derive(Body, fnv1a_64(id))
|
||||
pub fn derive(self, domain: SeedDomain, id: u64) -> Self;
|
||||
pub fn atlas_rng(self) -> AtlasRng; // integer-only LCG stream (D-010)
|
||||
pub fn seed(self) -> u64; // raw — for SimRng/ChaCha or further derive()
|
||||
@@ -1466,10 +1467,14 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
`derive(domain, id) = splitmix64(self.0 ^ splitmix64(domain as u64)) ^ splitmix64(id)`.
|
||||
Properties: deterministic; domain-separated (distinct `SeedDomain` tags never share a stream); full avalanche (splitmix64 on each input); integer-only (D-010 #4); chainable (`root → Body → Layer3Settlement → Quarter → Block`). The output is well-distributed, so `AtlasRng::new` is fed the derived seed directly — no `| 1` or golden-ratio pre-mix guard.
|
||||
|
||||
**Body identity (the `Body` domain id).** Bodies are identified by a *string* `body_id`, not a numeric StableId, so `SeedDomain::Body` is keyed by **FNV-1a (64-bit) of `body_id`** — the repo's standard deterministic `&str → u64` convention (matching `TemplateId`/`TriangleId`). `SeedChain::for_body(world_seed, body_id)` (`root(world_seed).derive(Body, fnv1a_64(body_id))`) is the single sanctioned path; callers must use it rather than inventing their own string→u64 hash, or they would silently derive divergent worlds from the same seed — the very class of nondeterminism this record exists to kill, one level up.
|
||||
|
||||
**Stability guards.** `SeedDomain` carries explicit `#[repr(u64)]` discriminants and is append-only; the unit test `seed_domain_discriminants_are_pinned` fails CI if any is renumbered (which would re-roll every world). `AttractorType` likewise carries explicit `#[repr(u8)]` discriminants because it is cast `as u8` as a sort key (`features.rs`); reordering it would change attractor ordering and flip the cascade golden.
|
||||
|
||||
**Scope of effect (verified 2026-05-23):** SeedChain changes only the RNG-*using* layers — the existing `skeleton_gen.rs` (Layer 4 block placement) and the future Layer-3 settlement placement (#955). It does **not** affect Layer 0 heightmaps (produced by the Python `planet_simulation` pipeline, seeded separately via `--seed`, committed as `heightmap.png` files) nor Layer 1 (`drainage`/`features`/`subbiome` are RNG-free — pure functions of the heightmap). The #952 Layer 0→1 golden fixtures are therefore SeedChain-independent and can be captured in any order relative to the SeedChain work.
|
||||
|
||||
- **Rationale:** Three seeding paths had drifted apart — `AtlasRng` (LCG, "callers pre-mix"), `EntityRng` (correct splitmix64 mixing), and ad-hoc `wrapping_add` in atlas callers — while the code already *named* a SeedChain that didn't exist. A single typed derivation chain with domain separation makes every sub-stream reproducible from one world seed, eliminates the `(seed,id)` collision class `wrapping_add` invites, and gives the determinism harness (#952) a stable contract to verify against. Promoting one mixer prevents two divergent implementations.
|
||||
- **Implementation:** #952 (Phase 4, epic #750) — add `server/src/seed.rs`, thread `SeedChain` through the existing atlas RNG callers, then capture the golden fixtures (SHA-256 of `heightmap.png` + msgpack of `Layer1Output`). Pre-Phase-5: no savegames exist, so the seed-stream change needs no migration; D-202's `schema_version` lineage covers future changes once saves exist.
|
||||
- **Implementation:** #952 (Phase 4, epic #750) — `server/src/seed.rs` (incl. `for_body`/`fnv1a_64`), `SeedChain` threaded through the atlas RNG callers, an extensible cascade harness (`run_cascade`/`CascadeSnapshot`/`CascadeLayer`), and a golden-seed regression test (SHA-256 of `heightmap.png` + JSON-serialized `Layer1Output`, run at 256×128 so the river network is non-empty — JSON not msgpack, to match the diffable `golden_suite.rs` convention). Pre-Phase-5: no savegames exist, so the seed-stream change needs no migration; D-202's `schema_version` lineage covers future changes once saves exist.
|
||||
- **Raised by:** Jeroen + Claude, `/whats-next` refinement of #952, 2026-05-23.
|
||||
- **Cross-reference:** [D-010](#d-010) (determinism — integer-only, seed→identical output), [D-200](#d-200) (three-tier execution model), [D-208](#d-208) (RNG-free drainage), [D-223](#d-223) (names-only pool — placement uses seeded RNG), `simulation/rng.rs` (EntityRng / splitmix64 precedent)
|
||||
- **Dissent:** None
|
||||
|
||||
Reference in New Issue
Block a user