From de9142ce37b589d42eafaa7bb11fba3133ddade8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 11:51:09 +0200 Subject: [PATCH] feat(simulation): SeedChain seed-derivation primitive in server/src/seed.rs (#952, D-224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New top-level seed module that owns every deterministic-RNG primitive: - splitmix64 — the one canonical mixer (was duplicated as a private fn in simulation/rng.rs; EntityRng now imports the shared one, no behavior change) - AtlasRng — moved here from atlas/rng.rs (it is a generation-RNG primitive, not atlas-specific); atlas now depends on seed, not the reverse - SeedDomain — append-only domain tags (Body/Layer1Topography/Layer3Settlement/ Layer4Quarter/Block/Npc) for collision-proof per-domain seed separation - SeedChain — root(world_seed) → derive(domain, id) → atlas_rng()/seed(), per the D-224 formula splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id) Structural move only — SeedChain is not yet threaded through the cascade callers (skeleton_gen still uses ad-hoc wrapping_add pre-mixing); that is the next step. Unit tests cover the splitmix64 known-vector, avalanche, determinism, domain/id separation, and chain composition. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/atlas/district_mix.rs | 2 +- server/src/atlas/mod.rs | 1 - server/src/atlas/rng.rs | 57 -------- server/src/atlas/skeleton_gen.rs | 2 +- server/src/lib.rs | 1 + server/src/seed.rs | 218 +++++++++++++++++++++++++++++++ server/src/simulation/rng.rs | 11 +- 7 files changed, 222 insertions(+), 70 deletions(-) delete mode 100644 server/src/atlas/rng.rs create mode 100644 server/src/seed.rs diff --git a/server/src/atlas/district_mix.rs b/server/src/atlas/district_mix.rs index a6d3e5504..c07a42064 100644 --- a/server/src/atlas/district_mix.rs +++ b/server/src/atlas/district_mix.rs @@ -12,7 +12,7 @@ //! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the //! district count computation. Seed-driven noise uses seeded RNG. -use crate::atlas::rng::AtlasRng; +use crate::seed::AtlasRng; use crate::simulation::generator::{DistrictType, PoliticalArchetype}; // --------------------------------------------------------------------------- diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs index 1e00810a0..16c4894b9 100644 --- a/server/src/atlas/mod.rs +++ b/server/src/atlas/mod.rs @@ -12,7 +12,6 @@ pub mod features; pub mod gen_queue; pub mod heightmap; pub mod layer1; -pub mod rng; pub mod skeleton_gen; pub mod subbiome; pub mod tile_condition; diff --git a/server/src/atlas/rng.rs b/server/src/atlas/rng.rs deleted file mode 100644 index 1d90c4d0e..000000000 --- a/server/src/atlas/rng.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Seeded LCG for deterministic generation (D-010). -//! -//! Shared by all atlas generation modules that need seeded randomness. -//! Uses Knuth's LCG parameters — integer-only arithmetic, no f32, D-010 compliant. -//! -//! Callers are responsible for any seed pre-mixing before calling `AtlasRng::new`. - -/// Seeded linear congruential generator (D-010). -pub struct AtlasRng { - state: u64, -} - -impl AtlasRng { - /// Create a new RNG from a pre-mixed seed. - /// - /// Callers must ensure the seed is non-degenerate (avoid passing 0 directly - /// if the seed could realistically be 0 — add a constant before calling). - pub fn new(seed: u64) -> Self { - Self { state: seed } - } - - fn next_u64(&mut self) -> u64 { - self.state = self - .state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - self.state - } - - /// Next pseudorandom `u32` (top 31 bits of the LCG state). - pub fn next_u32(&mut self) -> u32 { - (self.next_u64() >> 33) as u32 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn deterministic_sequence() { - let mut a = AtlasRng::new(42); - let mut b = AtlasRng::new(42); - for _ in 0..100 { - assert_eq!(a.next_u32(), b.next_u32()); - } - } - - #[test] - fn different_seeds_differ() { - let mut a = AtlasRng::new(1); - let mut b = AtlasRng::new(2); - let vals_a: Vec = (0..10).map(|_| a.next_u32()).collect(); - let vals_b: Vec = (0..10).map(|_| b.next_u32()).collect(); - assert_ne!(vals_a, vals_b); - } -} diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index cb5009f71..61601a0bb 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -20,7 +20,7 @@ use crate::atlas::block_irregularity::block_irregularity; use crate::atlas::district_mix::{compute_district_mix, population_tier}; -use crate::atlas::rng::AtlasRng; +use crate::seed::AtlasRng; use crate::simulation::generator::{ BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictId, DistrictLayoutMode, DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype, diff --git a/server/src/lib.rs b/server/src/lib.rs index 23e7bb3e1..0fa0a131d 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -8,6 +8,7 @@ pub mod cause_chain; pub mod knowledge; pub mod npc; pub mod perception; +pub mod seed; pub mod settings; pub mod simulation; pub mod storyteller; diff --git a/server/src/seed.rs b/server/src/seed.rs new file mode 100644 index 000000000..5c1ef707d --- /dev/null +++ b/server/src/seed.rs @@ -0,0 +1,218 @@ +//! Deterministic seed derivation and the generation RNG primitive (D-224). +//! +//! [`SeedChain`] is the single sanctioned path from the master world seed to any +//! per-body / per-layer / per-district sub-stream. Derivation is domain-separated +//! `splitmix64` mixing: deterministic, full-avalanche, integer-only (D-010 #4), +//! and chainable (`root → Body → Layer3Settlement → Quarter → Block`). +//! +//! This module is the home of every deterministic-seeding primitive: +//! - [`splitmix64`] — the one canonical mixer, shared with +//! [`crate::simulation::rng`], where `EntityRng` first introduced it +//! specifically to avoid the `wrapping_add` collision class +//! (`(seed=0,id=N) == (seed=1,id=N-1)`). +//! - [`AtlasRng`] — the integer-only LCG stream used by the generation cascade, +//! normally produced via [`SeedChain::atlas_rng`]. +//! - [`SeedChain`] / [`SeedDomain`] — the derivation contract itself. + +/// SplitMix64 finalizer — strong avalanche, applied to every seed input. +/// +/// Steele, Lea & Flood (2014). Integer-only (D-010 #4). `splitmix64(0)` returns +/// the documented reference value `0xe220a8397b1dcdaf`. +pub(crate) fn splitmix64(mut x: u64) -> u64 { + x = x.wrapping_add(0x9e3779b97f4a7c15); + x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb); + x ^ (x >> 31) +} + +/// Seeded linear congruential generator (D-010). +/// +/// Knuth's LCG parameters — integer-only arithmetic, no `f32`, D-010 compliant. +/// The generation cascade's pseudorandom stream. Normally seeded through +/// [`SeedChain::atlas_rng`]; `SeedChain` output is well-distributed, so no +/// caller-side pre-mixing is needed. +pub struct AtlasRng { + state: u64, +} + +impl AtlasRng { + /// Create a new RNG from a seed. + /// + /// A `SeedChain`-derived seed is already well-distributed. If seeding from a + /// raw value that could realistically be 0, mix it first (or derive it + /// through [`SeedChain`]). + pub fn new(seed: u64) -> Self { + Self { state: seed } + } + + fn next_u64(&mut self) -> u64 { + self.state = self + .state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.state + } + + /// Next pseudorandom `u32` (top 31 bits of the LCG state). + pub fn next_u32(&mut self) -> u32 { + (self.next_u64() >> 33) as u32 + } +} + +/// Seed-derivation domain (D-224). +/// +/// Each variant is a distinct domain tag, so a per-body stream and a per-district +/// stream derived with the same `id` never share a sequence. The discriminants +/// are explicit and **stable**: changing one re-rolls every world that derives a +/// seed through it, so they are append-only — never renumber an existing variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u64)] +pub enum SeedDomain { + /// Per-body generation (keyed by body StableId). + Body = 1, + /// Layer 1 topography (reserved — currently RNG-free; see D-224 scope note). + Layer1Topography = 2, + /// Layer 3 settlement placement (keyed by candidate/site index). + Layer3Settlement = 3, + /// Layer 4 quarter skeleton (keyed by quarter index). + Layer4Quarter = 4, + /// Block-level detail (keyed by block index within a quarter). + Block = 5, + /// NPC generation (keyed by NPC StableId). + Npc = 6, +} + +/// A position in the deterministic seed tree (D-224). +/// +/// `Copy` and cheap to pass by value. Construct with [`SeedChain::root`] from the +/// master world seed, then descend with [`SeedChain::derive`]. Materialize an +/// [`AtlasRng`] with [`SeedChain::atlas_rng`], or read the raw seed with +/// [`SeedChain::seed`] to feed `SimRng::new` or to derive further. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SeedChain(u64); + +impl SeedChain { + /// Root of the chain — the master world seed. + pub fn root(world_seed: u64) -> Self { + Self(world_seed) + } + + /// Derive a child seed for `(domain, id)`. + /// + /// `splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id)` (D-224, load-bearing). + /// Deterministic, domain-separated, full-avalanche, integer-only. + pub fn derive(self, domain: SeedDomain, id: u64) -> Self { + Self(splitmix64(self.0 ^ splitmix64(domain as u64)) ^ splitmix64(id)) + } + + /// Materialize the integer-only generation RNG at this point in the chain. + pub fn atlas_rng(self) -> AtlasRng { + AtlasRng::new(self.0) + } + + /// The raw derived seed — feed to `SimRng::new` or a further + /// [`SeedChain::derive`]. + pub fn seed(self) -> u64 { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splitmix64_known_vector() { + // Documented SplitMix64 output for seed 0. + assert_eq!(splitmix64(0), 0xe220a8397b1dcdaf); + } + + #[test] + fn splitmix64_avalanche() { + // Adjacent inputs produce uncorrelated outputs (the property that makes + // it preferable to wrapping_add). + let a = splitmix64(1); + let b = splitmix64(2); + assert_ne!(a, b); + assert!((a ^ b).count_ones() > 16, "weak avalanche between 1 and 2"); + } + + #[test] + fn atlas_rng_deterministic_sequence() { + let mut a = AtlasRng::new(42); + let mut b = AtlasRng::new(42); + for _ in 0..100 { + assert_eq!(a.next_u32(), b.next_u32()); + } + } + + #[test] + fn atlas_rng_different_seeds_differ() { + let mut a = AtlasRng::new(1); + let mut b = AtlasRng::new(2); + let vals_a: Vec = (0..10).map(|_| a.next_u32()).collect(); + let vals_b: Vec = (0..10).map(|_| b.next_u32()).collect(); + assert_ne!(vals_a, vals_b); + } + + #[test] + fn atlas_rng_from_seedchain_is_deterministic() { + let chain = SeedChain::root(42).derive(SeedDomain::Body, 7); + let mut a = chain.atlas_rng(); + let mut b = chain.atlas_rng(); + assert_eq!(a.next_u32(), b.next_u32()); + } + + #[test] + fn derive_is_deterministic() { + let a = SeedChain::root(42).derive(SeedDomain::Body, 7); + let b = SeedChain::root(42).derive(SeedDomain::Body, 7); + assert_eq!(a.seed(), b.seed()); + } + + #[test] + fn domains_are_separated() { + // Same root + same id, different domain → different stream. + let root = SeedChain::root(42); + let body = root.derive(SeedDomain::Body, 5).seed(); + let quarter = root.derive(SeedDomain::Layer4Quarter, 5).seed(); + let block = root.derive(SeedDomain::Block, 5).seed(); + assert_ne!(body, quarter); + assert_ne!(body, block); + assert_ne!(quarter, block); + } + + #[test] + fn ids_are_separated() { + let root = SeedChain::root(42); + assert_ne!( + root.derive(SeedDomain::Body, 1).seed(), + root.derive(SeedDomain::Body, 2).seed() + ); + } + + #[test] + fn different_world_seeds_diverge() { + let a = SeedChain::root(1).derive(SeedDomain::Body, 7).seed(); + let b = SeedChain::root(2).derive(SeedDomain::Body, 7).seed(); + assert_ne!(a, b); + } + + #[test] + fn chains_compose_deterministically_and_order_matters() { + let base = SeedChain::root(42).derive(SeedDomain::Body, 7); + // Deterministic at each depth. + assert_eq!( + base.derive(SeedDomain::Block, 3).seed(), + SeedChain::root(42) + .derive(SeedDomain::Body, 7) + .derive(SeedDomain::Block, 3) + .seed() + ); + // A deeper chain differs from deriving the same leaf off the root. + assert_ne!( + base.derive(SeedDomain::Block, 3).seed(), + SeedChain::root(42).derive(SeedDomain::Block, 3).seed() + ); + } +} diff --git a/server/src/simulation/rng.rs b/server/src/simulation/rng.rs index 2bbf17188..d7c8197f0 100644 --- a/server/src/simulation/rng.rs +++ b/server/src/simulation/rng.rs @@ -2,6 +2,7 @@ // Injectable ChaCha RNG resource for deterministic replay (D-030) // Ensures same seed produces same outcomes +use crate::seed::splitmix64; use bevy_ecs::prelude::*; use rand::SeedableRng; use rand_chacha::ChaCha20Rng; @@ -61,16 +62,6 @@ impl EntityRng { } } -/// Splitmix64 mixing function — good avalanche properties for seed derivation. -/// Ensures that similar inputs (e.g., consecutive StableIds) produce -/// uncorrelated output seeds. -fn splitmix64(mut x: u64) -> u64 { - x = x.wrapping_add(0x9e3779b97f4a7c15); - x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); - x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb); - x ^ (x >> 31) -} - #[cfg(test)] mod tests { use super::*;